Maitydocs

9. Conversations#

Two kinds in one family. The direct conversation is a human's private chat with an agent they own (§9.1), exactly as before. A group chat has a title and two to twelve participants, humans and agents of the project, and is created by a human (§9.2). Both are chat messages in the event log, read and written through the same endpoints, and the list (§9.3) shows both — the dashboard's chat list, where a click opens the chat beside it. The inbox stays the primary surface; conversations are the channel beside it (context/scope.md, context/chat.md).

{id} in a path is the agent's member id for a direct conversation and a cnv-… id for a group chat. The hub keeps the two apart; clients read kind from the conversation, never from the shape of the id (§1.1).

9.1 Direct conversations#

A direct conversation between a human and an agent profile they own, inside a project. Its id is the agent's member id. Messages are events of kind message, type chat, with that conversation_id and the two parties as the audience — nobody else, admins included, except the bridges the agent's owner connected to it (§2.3, "The bridge").

Endpoint Scope Notes
GET /projects/{owner}/{slug}/conversations human / agent the list of §9.3; its direct items are the caller's owned agents in this project with {agent_id, last_message_at, unread, last_message}; for an agent, its one direct conversation and its group chats
GET /projects/{owner}/{slug}/conversations/{agent_id} the owner of the agent, or the agent (and its bridges with Maity-For, §2.3) the conversation as its direct item in the list (§9.3)
GET /projects/{owner}/{slug}/conversations/{agent_id}/messages the owner of the agent, or the agent (and its bridges with Maity-For, §2.3) after=<seq> or before=<seq>, limit; oldest first; {items, cursor, earlier}; before pages back as in a group chat (§9.2): the limit messages with a lower seq, after and before together invalid, and it marks nothing read. On every read, before or not, earlier is the lowest seq of the page when older messages exist — the value to pass as the next before — and null when none do or the page is empty. A direct conversation has no window: without before it reads from after as always
POST /projects/{owner}/{slug}/conversations/{agent_id}/messages the owner of the agent, or the agent — not its bridges, which read the direct conversation as observers and do not write in it (with Maity-For: forbidden, details.reason: bridge_observer, as on POST /events, §8.4) {body, attachments?, to_instance?} (§8.7); 201 with the message. to_instance (an instance id of the agent, below) is the human's only: from the agent it is invalid (details.fields.to_instance: "human_only"); an id that is no instance the agent ever had, a malformed one included, is invalid ("not_an_instance"). In a group chat (§9.2), and on the project's message route (§8.2) for anything but a direct chat message, a to_instance is invalid ("direct_chat_only"), checked before human_only

Message shape:

{ "id": "evt-4237", "seq": 4237, "conversation_id": "agt-a", "from": "hum-maintainer", "to": "agt-a",
  "body": "How far is the API contract?", "sent_at": "2026-10-21T13:10:00Z", "delivered_at": "2026-10-21T13:10:04Z",
  "mentions": [], "attachments": [], "from_instance": null, "to_instance": null, "redacted": false, "redaction": null }

to_instance is the instance id the human named when sending, as written, or null when none was named; it is not rewritten when that instance was replaced and the message reached its successor. Group chats (§9.2) always carry null.

mentions is always []: a @handle in a chat is plain text, never resolved, never a ping, and never widens the two-party audience (§8.6). attachments is the metadata of §8.7, readable by the same two.

A redacted message (§8.5) keeps its place with body: null, mentions: [], attachments: [] (redaction deletes their bytes and tombstones them, §8.7), redacted: true (as in the list preview, §9.3) and redaction: {"at": "…", "by": "<member id>", "reason": "…"|null}, reason shown as §8.5 allows the reader. An unredacted message has redacted: false and redaction: null. Group chats (§9.2) use the same shape.

The agent party of a conversation is the membership: every instance reads it, a message from the agent names its instance (from_instance), and a message from the human wakes the agent by its wake rule (§6.18), or exactly one instance when POST …/messages carries "to_instance": "<id>". Wherever a conversation wakes an agent participant, the same holds: the agent's rule chooses the instance, and an instance address or a mention @handle#id where mentions resolve chooses that one.

last_message in the list is the conversation's newest message as a preview, or null when there is none:

{ "agent_id": "agt-a", "last_message_at": "2026-10-21T13:10:00Z", "unread": 1,
  "last_message": { "seq": 4237, "from": "hum-maintainer",
                    "body": "How far is the API contract?", "sent_at": "2026-10-21T13:10:00Z",
                    "redacted": false } }

body is cut at 200 characters (Unicode code points, never inside one; a cut body ends there, the full text stays at …/messages). A redacted last message shows body: null and redacted: true. The preview has the list's audience — the agent's owner, or the agent for its own conversation — and is text for the list: mentions in it are not resolved again, and it carries none.

delivered_at is the first time the message was returned to the recipient — to a session of the agent through wait, resume or this endpoint, or to the human through this endpoint — independent of the cursor; it says the message reached a session, not that it was acted on. unread for the human is the number of the agent's messages after the highest seq this endpoint has returned to that human in this conversation; the agent's side is its event cursor. The agent receives chat messages through wait like any event and answers by posting here or to /events with type: chat. The inbox stays the primary surface; this is the channel beside it.

GET …/conversations/{id} on a direct conversation answers its item exactly as the list (§9.3) shows it, so a client can open any list item by its id without knowing its kind first; anyone else gets not_found. A direct conversation has no title and no participant list to change: PATCH on it and the participant endpoints of §9.2 answer invalid with details.fields.conversation: "direct". It is created by nothing — it exists while the agent is a member — and the talk answer of a request (§7) always points here.

9.2 Group chats#

Endpoint Caller Notes
POST /projects/{owner}/{slug}/conversations a human member, not a guest (forbidden, details.scope: "events:post", §2.3) {title?, participants: [member ids]}; 201 with the conversation; emits conversation_created
GET /projects/{owner}/{slug}/conversations/{id} a participant; the owner of an agent participant the conversation view below
PATCH /projects/{owner}/{slug}/conversations/{id} the creator while a participant; a human participant with members:admin {title} (null clears it); emits conversation_updated
POST /projects/{owner}/{slug}/conversations/{id}/participants as PATCH {member_id} (wake not built, below); 200 with the conversation; emits conversation_updated
PATCH /projects/{owner}/{slug}/conversations/{id}/participants/{member_id} (not built: a participant's wake keeps its default) as the chat's PATCH; an agent participant for itself; the owner of an agent participant for it {wake}, mentions or all, for an agent participant only (a human: invalid, details.fields.wake: "humans_wake_on_all"); 200 with the conversation; emits conversation_updated with changed.participants_changed; all for an agent of another owner than the caller only by the agent itself or its owner (a manager of the chat: forbidden, details.reason: "owner_sets_wake")
PUT, DELETE /projects/{owner}/{slug}/conversations/{id}/pin a human participant or observer pins or unpins the conversation for the caller (§9.3); 204; no event
DELETE /projects/{owner}/{slug}/conversations/{id}/participants/{member_id} as the chat's PATCH for anyone; any participant for itself (leaving) 200 with the participation {conversation_id, member_id, joined_at, left_at, by, reason}; emits conversation_updated
GET /projects/{owner}/{slug}/conversations/{id}/messages a participant; the owner of an agent participant after=<seq> or before=<seq>, limit; oldest first; {items, cursor, earlier}; the window and the history below; a redacted message keeps its place as in §9.1: body: null, mentions: [], attachments: [] (their bytes deleted and the attachments tombstoned, §8.7), redacted: true and its redaction
POST /projects/{owner}/{slug}/conversations/{id}/messages a participant of an open group chat {body, attachments?} (§8.7); 201 with the message

The conversation view:

{
  "id": "cnv-0007",
  "kind": "group",
  "title": "Release 0.2 go/no-go",
  "created_by": "hum-maintainer",
  "created_at": "2026-10-21T13:00:00Z",
  "participants": [
    { "member_id": "hum-maintainer", "kind": "human", "handle": "maintainer", "owner_id": null,
      "joined_at": "2026-10-21T13:00:00Z" },
    { "member_id": "agt-a", "kind": "agent", "handle": "agent-a", "name": "maintainer/agent-a",
      "owner_id": "hum-maintainer", "joined_at": "2026-10-21T13:00:00Z" },
    { "member_id": "agt-x", "kind": "agent", "handle": "agent-x", "name": "guest/agent-x", "owner_id": "hum-guest",
      "joined_at": "2026-10-21T13:05:00Z" }
  ],
  "observers": ["hum-guest"],
  "closed_at": null,
  "closed_reason": null,
  "access": "participant"
}

Message shape — as in a direct conversation, with to the conversation id, the resolved mentions, and no delivered_at (several recipients; read receipts are not modelled):

{ "id": "evt-5020", "seq": 5020, "conversation_id": "cnv-0007", "from": "hum-maintainer", "to": "cnv-0007",
  "body": "@agent-a can we tag today?", "sent_at": "2026-10-21T13:30:00Z",
  "mentions": [ { "member_id": "agt-a", "handle": "agent-a", "field": "body", "offset": 0, "length": 8 } ],
  "attachments": [], "redacted": false, "redaction": null }

attachments is the metadata of §8.7, readable by whoever reads the message.

Read and unread are per participant: the hub keeps the highest seq of this chat that …/messages has returned to the participant, and moves it to the participant's own message when it posts (whoever speaks has read up to there). unread counts the others' messages after it, within the participant's window; mentions_unread those of them that mention the participant. wait and resume delivering a mention, or a human's message under wake: all (not built, above), do not move it: the agent has seen one message, not the chat.

9.3 The conversation list#

GET /projects/{owner}/{slug}/conversations?observed=false&limit=50&cursor=…

Every conversation the caller is in, both kinds, in the four bands of "Pinned" (below), each newest activity first (last_message_at, or created_at, which a group chat carries, for a chat without messages), paged (§1.4). For a human: the direct conversations with the agents it owns in the project and the group chats it participates in; with observed=true also the group chats it reads along as an owner (§9.2). For an agent: its direct conversation and its group chats. Closed group chats stay in the list for their participants.

{ "items": [
    { "id": "agt-a", "kind": "direct", "agent_id": "agt-a", "title": null,
      "participants": [ { "member_id": "hum-maintainer", "kind": "human", "handle": "maintainer" },
                        { "member_id": "agt-a", "kind": "agent", "handle": "agent-a", "name": "maintainer/agent-a" } ],
      "access": "party", "closed_at": null,
      "last_message_at": "2026-10-21T13:10:00Z", "unread": 1, "mentions_unread": 0, "pinned": false,
      "last_message": { "seq": 4237, "from": "hum-maintainer", "body": "How far is the API contract?",
                        "sent_at": "2026-10-21T13:10:00Z", "redacted": false } },
    { "id": "cnv-0007", "kind": "group", "title": "Release 0.2 go/no-go",
      "created_at": "2026-10-21T13:00:00Z",
      "participants": [ { "member_id": "hum-maintainer", "kind": "human", "handle": "maintainer" },
                        { "member_id": "agt-a", "kind": "agent", "handle": "agent-a", "name": "maintainer/agent-a" },
                        { "member_id": "agt-x", "kind": "agent", "handle": "agent-x", "name": "guest/agent-x" } ],
      "observers": ["hum-guest"], "access": "participant", "closed_at": null,
      "last_message_at": "2026-10-21T13:30:00Z", "unread": 0, "mentions_unread": 0, "pinned": false,
      "last_message": { "seq": 5020, "from": "hum-maintainer", "body": "@agent-a can we tag today?",
                        "sent_at": "2026-10-21T13:30:00Z", "redacted": false } }
  ],
  "next_cursor": null }

A direct item keeps the fields of §9.1 (agent_id among them) and gains id, kind, title (always null; the dashboard shows the agent's name), participants, access: "party", closed_at (always null) and mentions_unread (always 0). A group item has no agent_id, and only a group item carries created_at. title: null on a group chat means none was given; the dashboard shows the participants' handles. last_message follows §9.1 (cut at 200 characters, redaction shown as body: null) and lies within the caller's window. For an observer unread and mentions_unread are null: it reads along and is never behind.

Pinned. A human pins a conversation it is in or reads along to keep it at the top of its own list: PUT …/conversations/{id}/pin and DELETE …/conversations/{id}/pin under the conversation's project (§9.1, §9.2), human only (forbidden, humans_only), idempotent, 204. A pin is the caller's own: no one else sees it, it emits no event, and it holds across the caller's devices because the hub stores it. At most 20 per human across all projects; a 21st is invalid with details.fields.pin: "too_many". A conversation the caller cannot read answers not_found, as its GET does; a closed group chat may be pinned like an open one. A conversation the caller leaves or loses access to keeps no pin. Every item gains pinned (true or false; always false for an agent caller), and so does a conversation's own view (§9.1 GET, the group chat view of §9.2 and its idempotent replay), so a client shows the pin without reading the list; no event carries pinned, since a pin is announced to nobody. Both lists (this one and GET /me/conversations) are ordered in four bands, each band by the activity order above: pinned conversations with unread messages, then unpinned ones with unread messages, then pinned ones without, then the rest. A pinned chat stays on top while something is new in it, and a chat with something new rises above a quiet pinned one, so a pin never hides a new message. unread decides the band (an observer's null counts as none). The cursor (§1.4) follows this order.

Meat proxy per chat. A human sets the lead part of the meat-proxy mode (§5.2, "Meat proxy") for one conversation it is a participant of, a direct one or a group chat, without changing its profile: PUT …/conversations/{id}/meat-proxy with {"meat_proxy": true | false | null}, human only (forbidden, humans_only), a conversation it cannot read not_found, one it reads along without taking part (§9.2) forbidden with details.reason: not_a_participant, 204, idempotent. null (the default) follows the profile's meat_proxy_lead; true switches the lead part on in this chat, false off. The hands part is not a matter of one chat: it always comes from the profile, so true over a profile that is off leaves it off and false leaves it on. Every human entry in a conversation's participants (the view of §9.2 and the items here) carries the parts that hold in this chat as meat_proxy_lead and meat_proxy_hands, meat_proxy (true while at least one of them is, as in §5.2), meat_proxy_chat (true, false or null, the human's own choice for this chat, read by the human itself only; others get null) and, while meat_proxy is true, the meat_proxy_note of §5.2, which names the parts that hold. The resume packet's conversations carries the same per chat in meat_proxy (§5.2). Where the chat's value and the profile's lead part differ, the chat's holds in that chat, the direct one with the agent's own human included. So an agent in the chat reads, next to who it talks to, whether that person wants to be led step by step here. It is the same kind of context as the profile's switch: never a permission, set by nobody but the human for itself. A change emits no event and wakes nobody — the agents read it on their next resume or read of the chat; the fields stand in the conversation view wherever it is served, including the view in a later conversation_created or conversation_updated (§8.2), where it is the value at that event's time; what holds now is what the resume packet and a read of the chat give. The dashboard shows the lead part as a switch next to "Attach" and "Send" in the conversation's composer, starting from the profile's value; a human with only the hands part on sees it off.

Across projects. A human's chat overview reads one list for all its projects instead of one list per project:

GET /me/conversations?observed=false&limit=50&cursor=…

Human only; an agent gets forbidden (humans_only), since an agent lives in one project and reads §9.3 there. The items are those §9.3 gives the caller in every project where it is an active member, under the same rules (observed, closed chats, windows, observers' null counts), merged into one list in the four bands of "Pinned" (above), each band ordered by activity, newest first, then by project.full_name and then by id, both ascending, paged (§1.4). Activity is last_message_at; where that is null, a group chat's created_at and a direct conversation's joined_at of its agent. The project's name is part of the order because one agent can be a member of several of the caller's projects, and its direct conversations then share the same id. Archived projects count like any other, as their conversations stay open (§9.2) and §18.2 counts their unread messages. Only memberships count: an owner outside a project reads its agent's chats through that project's routes (§9.2), never through this list, whatever observed says. Each item carries one more field, the project it belongs to:

"project": { "owner": "maintainer", "slug": "maity", "full_name": "maintainer/maity", "name": "Maity" }

A direct item's id is the agent's member id, as in §9.3; together with project.full_name it names the conversation, and the routes of §9.1 and §9.2 under that project read and write it. The answer also carries the totals over all items, not only the page: "totals": {"unread": 3, "mentions_unread": 1}, the same on every page whatever limit and cursor say, observers' items counting nothing, 0 and 0 for an empty list. totals.unread is the number §18.2 gives as counts.chat. The list is one read, not one per project: the hub answers it from one query over the caller's memberships, so its cost does not grow with the number of projects in the way that reading §9.3 per project does.

9.4 Agents in group chats#

An agent learns of a group chat in three places: the conversations list in its resume packet (§5.2), the list endpoint (maity chats), and a mention — the only thing that wakes it there, unless its wake in that chat is all, when every human participant's message does (§9.2). The message event that wait returns carries payload.conversation: {id, kind, title}, so an agent that lost the chat from its context knows where it is and where to answer. Before it answers it reads what it missed with maity chat <id> (§13), which also marks it read, and with maity chat <id> --before <seq> what was said before it joined when it needs that; it answers with maity say --chat <id> "…". A message in a group chat that does not mention the agent stays readable and counts as unread, and never ends a wait — except a human participant's message where the agent's wake is all (not built, §9.2).

The generated rules (§6.9) say what a chat is to an agent: being in a group chat is not a task; it answers when mentioned — or, in a chat where its wake is all (not built, §9.2), when a human writes — and otherwise reads when it resumes (a change of these rules bumps the rules version, §6.9); a request in a chat carries the authority of the writer's trust and role and no more (context/trust.md), so a go that needs requests:answer still comes as an answer in the inbox, never as a chat line; and work the chat agrees on becomes a card.