Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

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

use Osysharp.Conversations@0; · policy IsConversationStaff => …; · StartConversation(text) · Post(c, text, visibility) · Record(c, kind, source, facts:) · ConversationCopy.EventSentence

`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`.

preview2 examples compiled by CIconversationskitsecuritymessaging

Source on GitHub: Osysharp.Conversations.Sms — github.com/osysharp/conversations/sms ↗

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:

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#

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#

What is a conversation made of?#

  • 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). 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?#

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#

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?#

Everything an AI wrote wears the kit's own colour, ConversationAi, so a reader knows before reading a word:

entrythe desk seesthe 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 sentnothing: a draft never reaches their page
a reply it sent itself (Post(…, asAgent: "Shop assistant"))the AI's hairline and Sent by AIAI assistant beside its name
a reply a person sent from its draft (ApproveDraft)Drafted by AI, sent by Ines under itWritten 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:

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

Who is staff?#

Only the app knows, so the kit declares policy IsConversationStaff; — with no body — and the app defines it:

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?#

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:

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?#

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 and guest rows (a visit's own rows).

Can my app use its own kind of party — even one called Party?#

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:

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?#

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:

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#

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#

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(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?#

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.

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?#

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.

use Osysharp.Mail.Receiving@0;   // with Osysharp.Conversations, Osysharp.Conversations.Mail arrives by itself

app.Conversations = new ConversationsSetup {
  Channels = [new MailConversationChannel { Endpoints = ["[email protected]", "[email protected]"] }],
};
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:

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) 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?#

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): 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); 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 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?#

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.
app.Conversations = new ConversationsSetup {
  Channels        = [new MailConversationChannel { Endpoints = ["[email protected]"] }],
  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?#

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), or your own:

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",
};
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#

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, Bridges — a package that arrives when an app uses the two it completes). 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:

copyerased by
their messages' original textblanked to [erased] — the entry stays
the original a message arrived as (a mail)deleted
the canonical copy, and what memory derived from itremoved
renderings of their messagesdeleted
the subject of a conversation they startedremoved
files marked about them (MarkFileAbout)deleted
contact points, name, language — theirs and every party merged into theirsdeleted / blanked; your own fields → PartyKinds.Erase
the delivery records' addresses, and a merge's reasoncleared
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#

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:

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#

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).

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?#

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):

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?#

Each is a [Composable] for a page of your own, dressed by your theme:

componentwhat 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 eitherthe 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?#

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#

A support desk: who is staff, what they may do, and a customer's chat#

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#

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#

Related

The machine-translation kit — which language a text is in, and the text in another, with your own model

`Osysharp.MachineTranslation` tells which language a text is in — offline first, from its script or the commonest words…

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

guest rows (a visit's own rows)

A row a guest makes before signing in — a basket, a saved choice, what they typed at the checkout — belongs to their…

Visitor

`Visitor.Id` names the browser someone is using. The platform issues it on their first visit, proves it on every…

[Authority]

What an app that imports your entity may do to the `security { }` you declared on it. The default is `Narrowable` — the…

entity Sub : Base

Derives one entity from another. The subtype is its own type with its own name, its own security and its own workflows…

The Mail kit — send a message, keep a record, pick a sender

`Osysharp.Mail` is the contract between an app that sends mail and whatever carries it. `SendMail` writes the message…

The Sms kit — send a text message, keep a record, pick a carrier

`Osysharp.Sms` is the contract between an app that sends text messages and whatever carries them, shaped like the Mail…

[Attachment] — a file readable wherever its row is

`[Attachment]` on a `FileAsset` reference makes the file readable by exactly the people who may read the row holding it…

upload

`Upload(onUploaded: Ingest) { … }` is a file picker wearing whatever you put inside it. When someone chooses a file its…

Receiving mail — watched mailboxes (Gmail, Microsoft 365, IMAP)

An app WATCHES a mailbox — it is never a mail server. The mail arrives where it always did (a Google Workspace or…

Zip.List — what an archive holds

`Zip.List(bytes)` answers the names a ZIP archive holds, in archive order — read from its central directory only, so…