# The Conversations kit — one timeline per conversation, across every channel

> `Osysharp.Conversations` keeps a conversation with a customer as ONE ordered timeline — their messages, the team's replies, internal notes, AI drafts and events — whichever channel each arrived on. Every entry has a visibility (`Customer`, `Internal`, `AiInternal`) enforced by the kit's rules, so a note never reaches a customer whatever the app grants. The app says who its staff are with one policy and derives its own kind of `Party`.

<!-- id: ui-conversations-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/conversations-kit/ -->

## Summary        {#summary}
`use Osysharp.Conversations@0;` gives an app conversations with the people outside it — a chat from the widget, a
thread in the portal, a reply the desk writes — kept as **one ordered timeline per conversation**:

```osy syntax
var c = StartConversation("Where is my parcel?");          // a customer, signed in or not
Post(c, "It left the warehouse this morning.");              // the desk replies — the customer sees it
Post(c, "Courier is late again.", EntryVisibility.Internal); // a note — the customer never does
Record(c, "desk.answered", "Desk");                          // something that happened — said when it is read
```

Who may see each entry is a **rule**, not a toggle: the kit's floor keeps every internal entry away from everyone who is
not staff, through every way a row can be reached.

## Signature      {#signature}
```osy syntax
use Osysharp.Conversations@0;            // with Osysharp.Identity@0 and Osysharp.Workflow

policy IsConversationStaff => <who your staff are>;          // REQUIRED — the kit asks, the app answers
policy CollaboratesOnRecords => IsConversationStaff;          // REQUIRED too — who sits on a conversation (Osysharp.Presence)

// the customer's side — [AllowAnonymous]: a chat visitor or a signed-in customer
Conversation StartConversation(string text, string subject = "", string name = "", string email = "");
Message      Reply(Conversation c, string text);
// the team's side
Conversation OpenConversation(Party with, string subject = "");
Message      Post(Conversation c, string text, EntryVisibility visibility = EntryVisibility.Customer, string? asAgent = null);
Message      ApproveDraft(Message draft, string? editedText = null);
ConversationEvent Record(Conversation c, string kind, string source,
                         EntryVisibility visibility = EntryVisibility.Internal, DateTime? at = null,  // at: when it happened
                         string? facts = null, string person = "", Party? personParty = null);       // its facts, passed IN
string       EventWords(ConversationEvent e);       // what it says to the reader, in their language
Participant  Seat(Conversation c, Principal member);  SeatParty(c, party, role);  SeatAi(c, agentName);
// read state
void MarkRead(Conversation c);   int Unread(Conversation c);
// two people on one conversation — a colleague's reply written after the place you had on screen, or null
Message?     AnsweredSince(Conversation c, int seen);
// accounts — the sign-in flow links an account to its party (call it from AccountEvents.SignedIn)
void         LinkSignedInAccount(Principal account, string email, bool emailIsProven);
Party?       AccountParty(Principal account);
Party        CallerParty(string displayName = "", string? language = null);

app.Conversations = new ConversationsSetup { Parties = new MyParties(), Copy = new MyCopy() };   // optional — every field has a default
// what the app's own kinds of event say — `Copy`'s `EventSentence`, overridden for them
public class MyCopy : ConversationCopy { public override string EventSentence(ConversationEvent e) { … return base.EventSentence(e); } }

// what an app's own kind of party grants its customers (grants are never inherited)
policy IsCustomerParty(Guid party);       // the caller IS this party, or holds the continue link mailed to it
```

## Description    {#description}

### What is a conversation made of?   {#model}
- **`Conversation`** — subject, when it started, `LastEntryAt`, `LastCustomerEntryAt` (what "waiting on us" reads),
  and `ReplyChannel` — where a reply goes by default.
- **`Participant`** — a seat: exactly one of a **party** (someone outside), a **staff account**, or an **AI agent** by
  name, with a role (`Customer`, `Cc`, `Staff`, `Ai`, `Observer`) and a read mark.
- **`ConversationEntry`** — the timeline. A **`Message`** says something (a direction, a channel, the original text); a
  **`ConversationEvent`** records something that happened — its `Kind`, its `Source` and its FACTS (who it names, a
  party's words, a count, and `Facts`, a class of the writer's own), never a sentence. The sentence is said when the
  timeline is READ, in the reader's language, by `ConversationCopy.EventSentence`, which an app overrides for the
  kinds it records ([A history in the reader's language — keep what happened, say it when it is read](https://osysharp.com/reference/i18n/history/)). Pass the facts INTO `Record`: a timeline is often written by a body a customer
  or a timer drove, which only the kit's own code writes past.
- **`Party`** and **`ContactPoint`** — the people outside, and the ways to reach them (an address, a number, a chat
  visitor). A point is a CLAIM until a proof flow verifies it.

### What order are entries in?   {#order}
**One sequence per conversation, shared by every kind of entry.** Each entry's `Sequence` is assigned by the platform
when it is written — gap-free, and the same order however the entries were written: a reply, an event and a note in
one commit take three consecutive places. Read a timeline with
`ConversationEntry.Where(e => e.Conversation == c).OrderBy(e => e.Sequence)`.

### Who sees what — and why a note can never leak   {#visibility}
Every entry has a `Visibility`: `Customer`, `Internal` (a note, an event) or `AiInternal` (a draft, a suggestion).
The kit's rules are the FLOOR every grant stands on, the app's included:
- a caller who is not staff never **reads** an internal entry — through `Message`, `ConversationEvent`, the
  `ConversationEntry` base, or a rendering of it in another language;
- nor **writes** one, nor an outbound message, nor a message signed as somebody else;
- **nobody** — staff included — changes an entry's visibility. Approving an AI draft (`ApproveDraft`) posts a NEW
  customer-visible message pointing back at it; the draft stays internal.

Even an app that grants its customers every event (`allow read when IsAuthenticated` on `ConversationEvent`) shows
them only the customer-visible ones.

### How does a reader tell what an AI wrote?   {#ai-entries}
Everything an AI wrote wears the kit's own colour, `ConversationAi`, so a reader knows before reading a word:

| entry | the desk sees | the customer sees |
|---|---|---|
| a draft it left (`AiInternal`) | its card in the AI's wash and hairline, a sparkle where an avatar would be, **AI draft — not sent** | nothing: a draft never reaches their page |
| a reply it sent itself (`Post(…, asAgent: "Shop assistant")`) | the AI's hairline and **Sent by AI** | **AI assistant** beside its name |
| a reply a person sent from its draft (`ApproveDraft`) | **Drafted by AI, sent by Ines** under it | **Written with AI, checked and sent by Ines** under it |

The mark is `AiMark(words)` and the line under an approved reply is `AiDraftedNote(message, desk)`, both `[Composable]`, so
a page of your own says it the same way. The colour is a design token in light and dark, contrast-checked as text on the
surface and on its own wash. Re-theme it in your app's theme, or tie it to a colour you already have:

```osy syntax
theme Desk {
  Colors {
    ConversationAi = Modes.Of(light: Palette.From("#5B3FA8"), dark: Palette.From("#C4B0F7"));
  }
}
```

### Who is staff?   {#staff}
Only the app knows, so the kit declares `policy IsConversationStaff;` — with no body — and the app **defines** it:

```osy syntax
policy IsConversationStaff => RoleGrant.Any(g => g.Member == user && (g.Role == AppRole.Agent || g.Role == AppRole.Lead));
```

Leaving it out is a compile error naming the kit and the policy. Include whatever account a channel's receiver runs as
(an integration account) — it writes customers' mail into conversations.

### What may staff do?   {#staff-grants}
What the app grants. The kit states the customer's side — their own conversations, their customer-visible entries,
writing as themselves — and leaves the team's to the app, so a helpdesk that limits agents to their group's
conversations says so in its own rules:

```osy syntax
partial entity Conversation { security { allow read, create, update when IsConversationStaff; } }
partial entity Message      { security { allow read, create, update when IsConversationStaff; } }
partial entity MessageAttachment { security { allow read, create when IsConversationStaff; } }   // the files on them
// …and Participant, ConversationEntry, ConversationEvent, MessageText, ContactPoint, Party
```

### How does a chat visitor get in — and stay out of everyone else's?   {#visitors}
A visitor's party is made by their own visit (`Party.CreatedByVisit` is stamped with `Visitor.Id` and can never be
written to another), and their seat is theirs alone: a non-staff caller takes a seat only in a conversation they
opened. A visitor point (`ContactPoint` of kind `Visitor`) is made only with the caller's own proved visit id, and
nobody reads its value. See [Visitor](https://osysharp.com/reference/ui/visitor/) and [guest rows (a visit's own rows)](https://osysharp.com/reference/security/guest-rows/).

### Can my app use its own kind of party — even one called `Party`?   {#parties}
Yes. `Party` is a base: a helpdesk writes `entity Customer : Osysharp.Conversations.Party`, a CRM `entity Contact : …`,
and an app may take the very same short name — `entity Party : Osysharp.Conversations.Party` — because the namespace
tells them apart. One table, the kit's rules on your rows, a read through the kit's base answering your type. Make the
kit create your kind:

```osy syntax
public class DeskParties : PartyKinds {
  public override Osysharp.Conversations.Party New(ContactPointKind kind, string value, string displayName) {
    return new Customer { DisplayName = displayName, Tier = "new" };
  }
}
app.Conversations = new ConversationsSetup { Parties = new DeskParties() };
```

`Party.Account` — what opens the portal — is written only by the sign-in flow.

### Who is this — and when do two parties become one?   {#resolution}
A message is FILED under a party: the one holding its address **proven**, else the newest one **claiming** it (a
customer who mailed before), else a new one — `PartyKinds.Resolve`, which a CRM overrides to match on more. Filing
grants nothing: whose history a browser may read is the kit's rules alone, and they read proofs.

- **A claim is not proof.** An address typed in the widget is a claim on the visitor's OWN party; a mail received
  FROM an address is a claim too (a From header can be forged); an agent's "confirmed on the phone" is `AssertedBy`.
  None of them writes `VerifiedKey`.
- **Proof is something we sent, coming back.** "Written to us before?" in the widget (`ConversationProveAddress`, or
  `SendAddressCode` + `ProveAddress`) mails a six-digit code — kept only as a keyed fingerprint, fifteen minutes, five
  tries — and the right code proves the address. If the desk already knows that address — proven, or claimed by a
  customer without an account — the visitor IS that customer: their party is merged into it, automatically, and
  their browser reads that history from then on.
- **Merging is the desk's, and undoable.** `MergeParties(kept, absorbed)` moves the absorbed party's points, seats
  and authorship to the kept one (a workflow writes them — history is otherwise fixed for everyone), leaves the
  absorbed party as a tombstone pointing at it (so late mail lands on the survivor), records every moved row on a
  `PartyMerge`, and writes an event on each conversation it touched. `UnmergeParties(id)` puts it all back.
  `PartyKinds.Merge(kept, absorbed)` copies your own fields. **Two parties that both have an account are never
  merged** — that would show each person the other's conversations.
- **Signing in links the party whose address is proven** — call `LinkSignedInAccount(account, email, proven)` from
  your `AccountEvents.SignedIn`. No proven holder: the account gets a party of its own, with its address proven. A
  claim is never enough.
- **`PartyCard(partyId)`** shows the desk all of it: each point and how far it is proven, who is probably the same
  person (`ProbablyTheSame` — a suggestion, never a merge by itself), and the merges, with Undo.

**Grants are never inherited, so your kind of party says who reads it.** The kit's `IsCustomerParty(Id)` is the
customer's side, whatever way they arrived — a signed-in account, the browser that started the chat, a browser it was
continued in, or the continue link itself for the one call it is opened in:

```osy syntax
entity Party : Osysharp.Conversations.Party {
  security {
    allow read, create, update when IsConversationStaff;
    allow read when IsAnonymous || IsAuthenticated where IsCustomerParty(Id);
    allow read, create, update when IsAuthenticator;   // the sign-in flow links an account to its party
  }
}
```

### A signed-in customer speaks as their account's party   {#accounts}
Once the sign-in flow has linked an account (`LinkSignedInAccount`, above), `StartConversation` from that customer
speaks as their account's party (`CallerParty`), not as a fresh visitor, so whatever they start in the portal is in
their history on every device. `AccountParty(account)` answers that party (its survivor, after a merge), or null.

⚠ **A party your staff make carries no visit** — set `CreatedByVisit = null` as you make it. The default stamps the
browser that made it, and an agent's own browser would then read that party's conversations as their customer.

### Two people answering one conversation   {#collision}
`AnsweredSince(c, seen)` is a colleague's customer-visible reply written after `seen`, the last `Sequence` the writer
had on screen — your own replies never count. Ask it before sending and refuse with their name, so a customer is
never answered twice; once the writer has read it, their next send goes out.

### What is unread?   {#unread}
`Unread(c)` counts the customer-visible entries above the caller's read mark, never their own; `MarkRead(c)` moves the
mark to the newest place. A note is never unread to a customer, because they cannot see it.

### How does email or SMS plug in?   {#channels}
A channel is an **adapter**: an implementation of `IConversationChannel` the app names in its setup. The kit queues a
customer-visible reply for delivery on the conversation's reply channel — the one the customer last wrote on — and
sends it after the commit; a reply on the web needs no delivery.

```osy title="a channel, and the app naming it" syntax
public class MailChannel : IConversationChannel {
  public string Key { get; } = "email";
  public ContactPointKind Reaches { get; } = ContactPointKind.Email;
  public bool IsReady() { … }                                         // false: replies are HELD, with the reason
  public string Send(OutboundMessage message, string idempotencyKey) { … return messageId; }
  // optional (defaults): bool ThreadsNatively() — false threads by the window rule; string NotReadyReason()
}

app.Conversations = new ConversationsSetup { Channels = [new MailChannel()], ThreadWindow = TimeSpan.FromDays(7) };
```

Receiving is the adapter's: its receiver maps what arrived into an `InboundMessage` and calls `ReceiveInbound`, running
as an account your `IsConversationStaff` admits. The kit answers it: a second delivery of the same id is the same
message; an auto-reply is recorded as an internal event and never answered; a message continues the conversation its
thread ids point at **only when its sender is in that thread** (seated, or written to in it) — a stranger quoting a
forwarded Message-ID starts a new conversation instead. A channel with no threading of its own (SMS) is threaded by the
window: the sender's latest conversation on that number, if it moved within `ThreadWindow`. `DeliveryReport(channel, id,
outcome)` records what the carrier said afterwards.

### Which channels exist?   {#email}
`osy kits Osysharp.Conversations` lists the channels the project you run it in can see:

Generated from the kits in scope (`osy kits Osysharp.Conversations` lists the same):

- `MailConversationChannel` — `Osysharp.Conversations.Mail` (arrives by itself: it completes Osysharp.Conversations + Osysharp.Mail.Receiving)
- `SmsConversationChannel` — `Osysharp.Conversations.Sms` (arrives by itself: it completes Osysharp.Conversations + Osysharp.Sms)

**Email — `Osysharp.Conversations.Mail`.** Replies go through `Osysharp.Mail`'s outbox, from the address the customer
wrote to, threaded with `In-Reply-To` and `References`; the outbox's Message-ID becomes a thread key, so the customer's
answer continues the conversation. `ReceiveMail(MailMessage)` is its receiving entry point: it reads the sender and
the name they gave, which of your `Endpoints` it came to, everyone else it was addressed to, and whether it is
automatic (`Auto-Submitted`, `Precedence`, the `X-Autoreply` family). A bounce marks the delivery it answers failed,
and its sender — the mail system — is never listed as a customer. Until the mail sender is ready, a reply is held with
the sender's own reason. `PlusAddressReplies = true` adds a `Reply-To` naming the conversation
(`support+c-<id>@acme.com`) for mail clients that drop the thread ids; the address only points — its writer must still
be in the thread.

```osy title="email as the desk's channel" syntax
use Osysharp.Mail.Receiving@0;   // with Osysharp.Conversations, Osysharp.Conversations.Mail arrives by itself

app.Conversations = new ConversationsSetup {
  Channels = [new MailConversationChannel { Endpoints = ["support@acme.com", "billing@acme.com"] }],
};
partial entity OutgoingMail { security { allow create, read when IsConversationStaff; } }   // staff send the replies
```

**An address staff set.** `Endpoints` is a list written in the source. When the support address is a setting instead,
subclass the channel and override `Addresses()` — it is asked when a mail goes or comes, so the setting is read then
rather than on every read of the conversations setup:

```osy title="the support address from a settings row" syntax
public class DeskMailChannel : MailConversationChannel {
  public override List<string> Addresses() { return [SupportSettings.Single().Address]; }
}
app.Conversations = new ConversationsSetup { Channels = [new DeskMailChannel()] };
```

**Mail arriving at a watched mailbox** comes in through `MailIntoConversations`, the receiver
[Receiving mail — watched mailboxes (Gmail, Microsoft 365, IMAP)](https://osysharp.com/reference/ui/mail-receiving-kit/) hands each mail to: `app.Mail.Receiver = new MailIntoConversations();`, or a subclass whose
`Arrived(result, mail)` does the app's part (a helpdesk opens or continues its ticket). What the sender wrote this time
is the message and what their client quoted is kept beside it (`Message.QuotedText`, shown behind "Show quoted text";
see the Mail kit's `SplitReply`); its attachments become the message's files; the whole original stays readable where
the message is (`Message.Original`). A mail whose thread ids were dropped still finds its conversation by a reference
in its subject: set the channel's `SubjectReference = "[#{reference}]"` and `Conversation.Reference` (a ticket number),
and every reply's subject carries `[#1419]` — but a mail is matched by it only when its sender passes the same join
check a threaded mail would, so a guessed number admits nobody.

### What can a message carry, and who sees its files?   {#files}
A message carries files — a customer's screenshot of what looks wrong, the source file a bug is in, a log, a mail's
PDF. Each is a `MessageAttachment` whose file is an `[Attachment]` ([[Attachment] — a file readable wherever its row is](https://osysharp.com/reference/storage/attachment/)): readable exactly where
the message is, so a customer reads the screenshot on a reply to them and never the one on an internal note.

- **What a file is, is read from its bytes.** A picture is shown in place only when its bytes ARE a PNG, JPEG, GIF or
  WebP; a name that lies (`invoice.png` holding text) is refused on upload and kept as a plain download when it
  arrives by mail — a mail is never lost. Source code (by its extension) is previewed with line numbers and coloured;
  plain text is previewed; a ZIP is listed ([Zip.List — what an archive holds](https://osysharp.com/reference/stdlib/zip/)); everything else is a download. HTML and SVG are code here,
  shown as their source and never rendered, and every download is `Content-Disposition: attachment`.
- **Limits are sentences.** `MaxFileBytes` (10 MB) and `MaxMessageBytes` (25 MB) are the app's to set; over either the
  writer is told which file, how big, and the limit, while they can still do something about it.
- **Adding files.** `Post`, `Reply` and `StartConversation` take `files:`; the composer and the widget take a
  screenshot PASTED into the message box, a file DROPPED on them, or picked ([upload](https://osysharp.com/reference/ui/upload/) `onFile:`, `multiple:`).
  `KeepUpload(uploaded)` keeps a private upload as the writer's own file — a signed-out visitor's is their visit's —
  and the reply functions take the ids. Staff send annotated screenshots back the same way.
- **A pasted snippet is code.** Text between ` ``` ` lines in a message is shown as code, coloured for the language
  named after the opening fence.
- **Erasure and retention reach files — the right ones**: a person's erasure deletes the files MARKED about them
  (`MarkFileAbout(attachment, party)`, the desk's to say) and the original their message arrived as, never a file only
  because they sent it (it may be their company's, or somebody else's); a conversation that reaches the end of its
  period takes all of its files.

The kit's coloured code is pure Osy#: `Highlight(code, language)` answers lines of tokens (keyword, type, string,
number, comment) for C#, Osy#, TypeScript/JavaScript, Python, SQL, shell, JSON/YAML, HTML/XML, CSS and the C family,
and plain numbered lines for anything else. `CodeView(lines)` renders them; `PreviewOf(attachmentId)` answers a file's
first 400 lines (or a ZIP's listing) through the read rules.

### What happens when the customer leaves the chat?   {#continuation}
They get the answer by mail. A reply the desk writes in the chat that the customer has **not read** after
`ContinueByAfter` (ten minutes by default) goes out on the channel that reaches `ContinueOn` — a `ContactPointKind`,
`ContactPointKind.Email` by default, so the mail channel — to the address they left in the widget: **every** reply they have not read, in one mail, with a link that continues the conversation in any
browser. Read in time — the transcript marks what it shows as read — and nothing is sent.

- **One mail per batch.** The clock runs from the first unread reply and is not restarted by the next; a reply that
  has been mailed is never mailed again. Each reply the mail carried is recorded as a delivery on that channel, so the
  desk reads it as mailed.
- **Their answer lands in the same conversation.** The mail's id is one of the conversation's thread keys, so a reply
  to it continues the chat (it passes the join check — they are its customer), and from then on the desk's replies go
  by mail until the customer writes in the chat again.
- **The continue link works once.** It is a `grant` (`ConversationContinueLink`, three days), and it does nothing on
  opening — a mail scanner that follows it continues nobody. The page it opens (`ConversationContinuePage`, at
  `ContinuePath`) has one button; pressing it makes THAT browser the party's (a `Visitor` point of its own visit) and
  takes back every continue link to the party. Then it goes to `ChatPath`, where the widget shows the whole timeline —
  the mail they wrote included, badged `email`.
- **The widget asks for the address only where it is used** — when a follow-up channel is set up — and says what it
  is for (`ConversationCopy.WidgetMailNotice`). It is a CLAIM on the visitor's own party, never attached to anyone
  else holding that address.

```osy title="mail chat replies the customer has not read after five minutes" syntax
app.Conversations = new ConversationsSetup {
  Channels        = [new MailConversationChannel { Endpoints = ["support@acme.com"] }],
  ContinueByAfter = TimeSpan.FromMinutes(5),      // null: never
  ContinueOn      = ContactPointKind.Email,       // the channel that reaches an address carries it (the default)
  ChatPath        = "/help",                      // the page your widget is on
};
```

The words are yours to change: override `ConversationCopy.FollowUpIntro`, `FollowUpContinue`, `FollowUp` or
`WidgetMailNotice` and name it as `Copy`.

### Which language is it in — and what is kept?   {#language}
Every message keeps its **original** — what was written or received — and gains a **canonical copy** in the desk's
language (`CanonicalLanguage`, `"en"`), which search, memory and analysis read. A reply the desk writes reaches each
customer **in their language**: a rendering (`MessageText`) recorded as the text they were SENT (`WasDelivered`), which
is what every channel delivers. Translation and detection are a provider you choose — the app's own model through
`Osysharp.Conversations.MachineTranslation` ([The machine-translation kit — which language a text is in, and the text in another, with your own model](https://osysharp.com/reference/ui/machine-translation-kit/)), or your own:

```osy title="the app's own model as the translator" syntax
use Osysharp.MachineTranslation@0;   // with Osysharp.Conversations, its bridge arrives by itself
app.Conversations = new ConversationsSetup {
  Translator = new ModelConversationTranslator { Translator = new ModelTranslator { KeySecret = "ModelKey" } },
  CanonicalLanguage = "en",
};
```

```osy title="or bring your own" syntax
public class MyTranslator : IConversationTranslator {
  public string Key { get; } = "mine";
  public bool IsReady() { return Secret.TranslatorKey != ""; }
  public string NotReadyReason() { return "Give the secret TranslatorKey a value."; }   // optional
  public string Detect(string text) { /* a BCP-47 tag */ }
  public string Translate(string text, string from, string to, List<GlossaryTerm> glossary) { /* … */ }
}
```

- **Writing a message never waits for a translator.** The commit that stores a message stores its redacted original as
  the canonical copy and queues a `MessageLanguageWork`; the `MessageLanguage` workflow does the detection and the
  translation on a clock due at once, the same way a delivery is sent. A message is posted even while the translator
  is down.
- **A translator that fails** is tried twice more, two minutes apart, then the work is `Unavailable` and the desk is
  told why (`LanguageStatusOf(conversation)`, shown by `ConversationLanguageNotice`). One that is **not ready** — no key —
  is not tried at all, and its `NotReadyReason()` is what the desk reads. `TranslateAgain(message)` and
  `TranslateConversationAgain(conversation)` try again once it is back.
- **A reply waits for its renderings** — its delivery is `AwaitingLanguage` — for at most a quarter of an hour, then goes
  out in the original: an answer in the desk's language beats none.
- **With no translator** nothing is queued: the language stays unknown and the redacted original IS the canonical copy.
- **A customer's language** is set the first time one of their messages is detected, and
  `ConversationEvents.LanguageKnown(conversation, party, language)` is called — override it to route by language or set
  your own record's language. Staff may change it.
- **The desk** reads a customer's message in its own language, marked "Translated from Swedish", with the original a
  click away; its own replies say "Sent in Swedish" with what the customer received a click away. **The customer**
  reads the rendering, marked "Translated from English". Language names come from `ConversationCopy.LanguageName`.
- **A message the team takes down for a customer** (from a phone call, a walk-in) goes through `TakeDown(conversation,
  party, text)` — scrubbed, redacted and translated like one the customer wrote.
- **The chat widget speaks the app's words** — its title, intro, labels and button are `ConversationCopy` virtuals
  (`WidgetTitle`, `WidgetIntro`, `WidgetStart`, `LauncherLabel`…), so the app's own locales translate them.

**Scrubbed at rest, redacted before a model.** `ConversationRedactor.ScrubAtRest` runs on every message as it is STORED
— card numbers (Luhn-checked, kept as `[card ••••1234]`), a security code beside one, typed passwords and API keys — in
both directions and on every channel: storing a full card number is a PCI DSS breach on its own. `ForAModel` takes
addresses, phone numbers and account numbers out too, before anything reaches a translator or the canonical copy; the
original keeps them. Override either method, or replace the redactor whole (`Redactor = new …`).

### Keeping, forgetting and handing over   {#erasure}
**Its fields are personal data** (0.13.0): a message's text, quoted text and redacted copy, a rendering, a subject, an
event's sentence, a file's name are `Personal.Content`; an address, a number, a delivery's recipient are
`Personal.Contact`; a party's name and the name an entry is signed with are `Personal.Identity`. So none of them reaches
the audit trail, and a kept conversation can be restricted. The kit announces the three levels in its `package.osy`, and
an app says nothing: a level it does not map is read through each row's own security — a customer reads their own
words, the desk the conversations it works. An app that wants a level read by fewer maps it, or maps the kit's level onto
one of its own (`Includes = [Osysharp.Conversations.Personal.Content]`, see [[security-personal-data#kit-levels]]).

**What is erased, and when, is a privacy request's plan** — `Osysharp.Privacy.Conversations`, which arrives by itself
with `Osysharp.Privacy` (see [The Privacy kit — erasure that keeps only what the company must or may](https://osysharp.com/reference/security/privacy-kit/), [Bridges — a package that arrives when an app uses the two it completes](https://osysharp.com/reference/project/bridges/)). That kit is this one's holder: a conversation that concerns a claim
(the app says which — `ConversationPrivacyRules.ConcernsAClaim`) is KEPT RESTRICTED for legal claims — every entry,
rendering, file and delivery, and who the person is — out of every ordinary read and read only through
`ConversationForAClaim` by whoever `HandlesClaims`; the person's other conversations, their contact points (unless a kept
complaint needs them) and the redacted copies are erased, and a conversation is erased for everyone its period after its
last entry — `app.ConversationsPrivacy`'s `KeepForMonths` by default, which whoever manages privacy changes on the
retention page. Nobody records an erasure any other way — not staff, not
an admin: `PartyErasure` is created only by a run, so no erasure bypasses the plan. This kit's own run does the writes,
reaching every copy it and its channels keep:

| copy | erased by |
|---|---|
| their messages' original text | blanked to `[erased]` — the entry stays |
| the original a message arrived as (a mail) | deleted |
| the canonical copy, and what memory derived from it | removed |
| renderings of their messages | deleted |
| the subject of a conversation they started | removed |
| files marked about them (`MarkFileAbout`) | deleted |
| contact points, name, language — theirs and every party merged into theirs | deleted / blanked; your own fields → `PartyKinds.Erase` |
| the delivery records' addresses, and a merge's reason | cleared |
| what a channel keeps (the mail outbox, the text outbox) | the channel's `EraseCopiesTo` → `EraseMailTo` / `EraseSmsTo` |

Each erasure is recorded (`PartyErasure`, with what it reached) and said on each conversation as an internal event.
**`ExportPartyData(party)`** (or `…AsJson`) answers a right-of-access request: every message they sent and were sent —
as they were sent, in their language — and their contact points; never a note.

### Copying somebody in, and splitting a message off   {#relations}
**`CopyIn(conversation, party)`** seats a party as `Cc`: from then on every customer-visible reply on the
conversation's channel goes to them too, and they read the conversation where its customer does. **`Uncopy(seat)`**
takes them off — nothing more is sent, and they no longer read it. For a page: **`CopyInByAddress(conversationId,
address, name)`** (the party already reached at that address, else a new one of the app's kind) and
**`UncopySeat(seatId)`**, both committed; **`CopiedOn(conversationId)`** lists who is copied, with their addresses.
Refused, by name: the conversation's own customer, somebody already copied, an address that is not one. Each move is
an internal line (`conversations.copied`, `conversations.uncopied`) naming the party, so erasing who they are reaches it.

**`SplitOff(message, subject, record)`** moves a message a customer wrote into a conversation of its own — a second
question asked in the first one's thread. Its writer is the new conversation's customer; the caller takes a staff seat.
It RECORDS the split (`ConversationSplit`) and the kit's own run makes the move when the caller commits — so commit the
app's side (a ticket, a link back) in the same commit:

```osy title="a second question becomes a ticket of its own" syntax
var into = SplitOff(message, "CSV export stuck at 99%", record: false);   // the app writes its own lines
var next = NewTicket("CSV export stuck at 99%", into, writer);
Link(next, ticket, "split");
UnitOfWork.Commit();                                                     // …and the kit's run moves the message
```

- **The message MOVES**: its files and renderings go with it, the mail thread key it arrived under moves too (a reply to
  that mail continues the new conversation), and the platform gives it its place on the new timeline. The old
  conversation keeps everything else.
- **Refused**: anything but what a customer wrote, and the only thing they wrote. The run checks again and refuses a
  record naming a conversation that has a message in it, or another customer's seat
  (`a-split-lands-only-where-its-writer-reads`).
- **`Conversation.SplitFrom`** says where it came from — a legal hold on that one still keeps it
  (Osysharp.Privacy.Conversations, `a-split-is-no-way-out-of-a-hold`).
- **On the desk**: `ConversationThread(conversationId, onSplit: …, splitLabel: …)` offers the split, as a quiet icon on
  each message a customer wrote, wherever they wrote more than one thing.
- **The app grants it**: `partial entity ConversationSplit { security { allow read, create when IsConversationStaff; } }`.

### Side conversations — asking somebody else, beside a conversation   {#side-conversations}
**`StartSideConversation(parent, subject, text, with)`** starts a conversation BESIDE another one, about it: mail to
somebody outside (`with`, a party with an address — a supplier, a carrier) or, with no one, a thread inside the team
whose every message is a note. It is a conversation of its own — its own seats, subject and mail thread — so the other
side's answer by mail continues it and nothing else, in their language, scrubbed at rest. The caller takes a staff
seat; `parent`'s timeline gets an internal line (`conversations.side-started`), and each answer from the other side
another (`conversations.side-replied`). Committed. For a page: **`StartSideConversationTo(parentId, address, subject,
text, name)`** (the party already reached at that address, else a new one of the app's kind; no address — inside the
team), **`SetSideDoneById(sideId, done)`**, and **`SideConversationsOf(parentId)`** — each with whom, its subject, how
many messages, whose turn it is, whether it is set aside, and what went wrong delivering it (a bounce).

```osy title="ask the carrier, beside the customer's conversation" syntax
var side = StartSideConversation(conversation, "Crushed pallet on Monday's run",
                                 "Can you check the pallet you delivered on Monday?", carrier);
```

- **The other side never sees the conversation it is beside.** They are seated in the side conversation alone; their
  mail carries its subject, words and thread ids — never the first conversation's reference or ids; and
  `Conversation.SideOf` is masked from everybody who is not staff (`a-side-conversation-never-shows-its-parent`). The
  reverse holds by the same rules: the first conversation's customer is not seated in the side one, and the lines
  about it on their conversation are internal.
- **Refused, by name**: starting one from a side conversation, with somebody already on the conversation, with no
  subject, with nobody's address.
- **The first mail is not dressed as an answer**: it keeps its subject (no "Re: "), because it answers nothing yet.
- **On the desk**: `ConversationThread` draws a side conversation where it was started, as a `SideConversationCard` —
  closed, who it is with, its subject, the newest message, whose turn it is, "Did not arrive — …" when the mail
  bounced; opened, its own thread and a composer that writes to the other side (or, inside the team, notes) and binds
  no key of its own. `SideConversationStarter(conversationId, onStarted, onCancel)` is the form to start one with — an
  app puts it in its own `Dialog`.
- **An answer on the web is told too.** The other side may answer signed in rather than by mail. Their message lands in
  the side conversation, and the kit's own run (`SideAnswerNoting`), never the person who answered, writes the line on
  the conversation it is beside, opens the side conversation again, and calls the app's
  `ConversationEvents.SideAnswered(side)`. A mailed answer calls the same hook, so a desk tells whoever holds the work
  in one place. `Conversation.IsSide` says that a conversation is a side one without saying beside what; the writer
  never reads `SideOf`.
- **A legal hold on the first conversation keeps its side conversations** (Osysharp.Privacy.Conversations), as it keeps
  a split.
- **An erasure of the first conversation's customer blanks what the desk wrote beside it** (`ErasureReach.DeskWordsBeside`):
  every desk message and note in its side conversations, their renderings and files, the copy the channel sent
  (`IConversationChannel.EraseCopyOf(externalId)`, which `Osysharp.Conversations.Mail` implements over the mail outbox's
  `ForgetSentMail`), the side conversations' subjects and the line that repeats one. The other side's words are theirs,
  and stay.

### How do text messages become a channel?   {#sms}
`Osysharp.Conversations.Sms` — which arrives by itself in an app that uses Conversations and Sms — makes text messages a channel, through `Osysharp.Sms`'s outbox (and whichever
carrier it is set up with — `Osysharp.Sms.FortySixElks` for 46elks):

```osy title="the desk's number as a channel" syntax
app.Sms = new SmsSetup { Sender = new ElksSmsSender { … } };
app.Conversations = new ConversationsSetup { Channels = [new SmsConversationChannel { Number = "+46766861004" }] };
```

- A reply goes out **from the channel's number** — never the app's alphanumeric `app.Sms.From`, because a name cannot
  be answered. A channel with a name, or no number, is not ready and says which.
- A text carries no thread id, so the kit's **window rule** threads it: a text from the same number to the same
  number within `ThreadWindow` (seven days) continues that number's latest conversation; after it, a new one starts.
- A reply longer than one text can carry (`MaxLength()` — 1530 characters for SMS) is refused when it is WRITTEN,
  with a sentence, rather than cut by a carrier later.
- `ReceiveSms(from, to, text, carrierId)` and `SmsDeliveryReport(carrierId, delivered, problem)` are what a carrier's
  webhooks hand over; the same carrier id twice is one message.

### Which components does it ship?   {#components}
Each is a `[Composable]` for a page of your own, dressed by your theme:

| component | what it is |
|---|---|
| `ConversationWidget(title, intro)` | the customer's chat — signed out or in: a first message (and an optional name), then this visit's or account's latest conversation |
| `ConversationLauncher(title, intro, label)` | the same chat as a button in the page's end corner, opening the widget in a panel over the page; place it once, in a layout, and it follows the person from page to page |
| `ConversationEvents.CustomerWrote(c, text, started)` | what the app hears when a customer writes in the chat — `started` for the message that began it — in the customer's own request, after the commit: a desk opens a ticket for a new chat here (set it as `Events` in the setup) |
| `slot help { … }` on either | the app's own help above the first message, while there is no conversation yet — a search of its help articles (`KnowledgeQuickSearch()`), opening hours |
| `ConversationTranscript(conversationId, desk, pageSize)` | the timeline the reader may see: messages as bubbles (their own on the right), events as a line, "Show earlier" past `pageSize`; it marks the conversation read |
| `ConversationThread(conversationId, desk, pageSize, opened)` | the same timeline as a DESK reads it — full-width entries a long reply reads well in, a note tinted and named "internal note", events as a quiet line, and (on the desk) what became of each reply sent on a channel: sent, sending, or not sent and why |
| `OpenThread(conversationId, take)` | what a desk's view of a conversation asks the server as it opens, in ONE call: its events in the reader's words, whether translation works on it, and the reader's mark moved to its newest entry (written only when it moved). Hand it to `ConversationThread` and `ConversationLanguageNotice` as `opened:` and neither asks for itself — a page beside other parts opens the conversation with one call |
| `ConversationComposer(conversationId, desk, placeholder, notes, label)` | write a reply — and attach files: pasted, dropped or picked; with `desk: true` an "Internal note" switch (`notes: false` hides it where there is nobody to keep a note from — a team's own chat); `label` names the field; tells the other side the writer is typing (on a one-second clock, never per keystroke) |
| `ConversationPresenceChip(conversationId, desk)` | who is typing; for the desk, which colleagues have it open |
| `ConversationBubble(message, desk, files)` | one message — author, channel, time, a badge on a note, the AI's mark on what an AI wrote, its words (a fenced snippet as code, quoted mail folded) and its files |
| `AiMark(words)` | the mark on what an AI wrote — a sparkle and its words in `ConversationAi` |
| `AiDraftedNote(message, desk)` | the line under a reply a person sent from an AI's draft: who checked and sent it |
| `MessageFiles(files)` | a message's files: pictures in place (a click opens them large, with a download), then a card per other file — code and text previewed, a ZIP listed, a download for each |
| `CodeView(lines, label, truncated, totalLines)` | code with line numbers, coloured; scrolls sideways rather than wrapping |
| `MessageBody(text, quoted)` | a message's words: prose, fenced code, and "Show quoted text" |
| `PendingFiles(files, onRemove)` | the files waiting to go with a composer's next message, each removable |
| `ConversationProveAddress()` | "Written to us before?" — a code to the address, and that address's conversations become this browser's; the widget shows it where the desk sends mail |
| `PartyCard(partyId)` | the desk's card for a party: points and how far each is proven, probable duplicates with a merge button, merges with Undo |
| `ConversationContinue(link)` | what a continue link opens: one "Continue here" button; served as `ConversationContinuePage` at `/conversations/continue/{link}` |
| `ConversationChatPage()` | the widget on a page of its own, at `/conversations/chat` — where a continued browser lands unless `ChatPath` names yours |

### How does a page stay live — and why can't a note leak through it?   {#live}
A transcript is an ordinary live read: when an entry is written anywhere, the page re-reads it **through the reader's
own rules**. Nothing carries the words — the platform's change signal names a row, not its text — so a note written
while a customer watches reaches their page as nothing at all. Typing and presence ride the kit's topics
`ConversationTyping` (both sides, a chat visitor included) and the desk's seats — the Presence kit's
`RecordPresence` on the conversation's id (staff only, by the app's `CollaboratesOnRecords`), which carry a name and a
flag, never a word. A visitor who is not in a conversation cannot join either.

## Examples       {#examples}

### A support desk: who is staff, what they may do, and a customer's chat   {#example-desk}
```osy title="the smallest correct consumer — staff, grants, and a visitor's first message" test app=ui-conversations-kit
app Desk {
  use Osysharp.Identity;
  use Osysharp.Conversations;
  use Osysharp.Workflow;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Conversations;

[Role] enum AppRole { Authenticator, Agent }
policy IsConversationStaff => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Agent);
policy CollaboratesOnRecords => IsConversationStaff;   // the desk's seats are Osysharp.Presence's — the same people

entity Agent : Principal {
  security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}

partial entity Party { security { allow read, create, update when IsConversationStaff; } }
partial entity ContactPoint { security { allow read, create, update when IsConversationStaff; } }
partial entity Conversation { security { allow read, create, update when IsConversationStaff; } }
partial entity Participant { security { allow read, create, update when IsConversationStaff; } }
partial entity ConversationEntry { security { allow read when IsConversationStaff; } }
partial entity Message { security { allow read, create, update when IsConversationStaff; } }
partial entity ConversationEvent { security { allow read, create when IsConversationStaff; } }
partial entity MessageText { security { allow read, create when IsConversationStaff; } }
// The files on messages: the kit grants the customer's side; the desk's is the app's, beside its Message grant.
partial entity MessageAttachment { security { allow read, create when IsConversationStaff; } }

// The desk answers, notes what it did, and records it — the customer sees the reply and nothing else.
void Answer(Conversation c) {
  Post(c, "It left the warehouse this morning.");
  Post(c, "Courier is late again.", EntryVisibility.Internal);
  Record(c, "desk.answered", "Desk");
}
```

### A desk that never answers twice, and its thread   {#example-collision}
```osy title="refuse a reply a colleague already gave, and show the thread as the desk reads it" test app=ui-conversations-kit-thread
app Desk {
  use Osysharp.Identity;
  use Osysharp.Conversations;
  use Osysharp.Workflow;
  use Osysharp.Ui;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Conversations;

[Role] enum AppRole { Authenticator, Agent }
policy IsConversationStaff => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Agent);
policy CollaboratesOnRecords => IsConversationStaff;   // the desk's seats are Osysharp.Presence's — the same people

entity Agent : Principal {
  security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}

partial entity Party { security { allow read, create, update when IsConversationStaff; } }
partial entity ContactPoint { security { allow read, create, update when IsConversationStaff; } }
partial entity Conversation { security { allow read, create, update when IsConversationStaff; } }
partial entity Participant { security { allow read, create, update when IsConversationStaff; } }
partial entity ConversationEntry { security { allow read when IsConversationStaff; } }
partial entity Message { security { allow read, create, update when IsConversationStaff; } }
partial entity ConversationEvent { security { allow read, create when IsConversationStaff; } }
partial entity MessageText { security { allow read, create when IsConversationStaff; } }
// The files on messages: the kit grants the customer's side; the desk's is the app's, beside its Message grant.
partial entity MessageAttachment { security { allow read, create when IsConversationStaff; } }
partial entity MessageDelivery { security { allow read when IsConversationStaff; } }

/// Send, unless a colleague answered after the place this writer had on screen.
public void SendFromDesk(Guid conversationId, string text, int seen) {
  var c = Conversation.Single(x => x.Id == conversationId);
  var first = AnsweredSince(c, seen);
  if (first != null) { throw new ValidationException(first.ByName + " already replied. Read it, then send."); }
  Post(c, text);
  UnitOfWork.Commit();
}

[Route("/desk/{id}")]
[Authorize(IsConversationStaff)]
[Render(CSR)]
component DeskThread(Guid id) {
  render { ConversationThread(id, desk: true); }
}
```

## See also       {#see-also}
- [security { }](https://osysharp.com/reference/security/entity-security/) — the rules the kit's floor is written in
- [[Authority]](https://osysharp.com/reference/security/contribution-authority/) — why a package's `deny` binds every app grant
- [guest rows (a visit's own rows)](https://osysharp.com/reference/security/guest-rows/) — `Visitor.Id` in a rule: a visit's own rows
- [Visitor](https://osysharp.com/reference/ui/visitor/) — the visitor the platform issues and proves
- [entity Sub : Base](https://osysharp.com/reference/entity/inheritance/) — deriving the app's own kind of party
- [The Mail kit — send a message, keep a record, pick a sender](https://osysharp.com/reference/ui/mail-kit/) · [The Sms kit — send a text message, keep a record, pick a carrier](https://osysharp.com/reference/ui/sms-kit/) — the carriers channel adapters build on
