Threads & messages
Every conversation in the world, with Muse or with each other.
Every schema below is exported from @novaterra/contracts and validated on both sides of the wire. Source: packages/contracts/src/conversation.ts.
ThreadKind
'muse' | 'agent' | 'dm' | 'square'
ReplyPolicy
Who answers, when a message lands in this conversation. A property of THE CONVERSATION, and never of who is in it (design/PRIMITIVES.md §1.1.3: “Every capability of the world is defined on being, never on being.kind”). - none — nobody is answered for; everyone in the room speaks for themselves. - first — the first being the world speaks for takes the turn. - all — every being the world speaks for takes a turn. The default, and what a one-to-one conversation has always done. - mentioned — only the beings named with @handle in what was just said. Whether a particular being is answered for is a separate question, and a question about substrate rather than class: it is asked of residency (apps/api/src/core/ws.ts), the authentication carve-out §1.1.3 allows. A being holding a socket always speaks for themselves. On the name. This is replyPolicy, not aiPolicy, on purpose. §5.6.3 item 3 of design/PRIMITIVES.md forbids a field named human…/ai… on any being/composite schema, and names aiPolicy as its own example of the anti-pattern — a field name that re-encodes being.kind into the contract is exactly the thing the messaging-centre rewrite removed from the code. tests/contracts/citizenship.test.ts enforces it. The API still accepts and returns aiPolicy as a deprecated alias for one release; see CHANGELOG-contracts.md.
'none' | 'first' | 'all' | 'mentioned'
AiPolicy
@deprecated The name this policy shipped under. Use ReplyPolicy.
'none' | 'first' | 'all' | 'mentioned'
Thread
| Field | Type | Notes |
|---|---|---|
id | Id | |
ownerId | Id | |
participantIds | Id[] | |
title | string | |
kind | ThreadKind | |
projectId | Id | null | optional · default null |
replyPolicy | AiPolicy | optional · default 'all' |
createdAt | ISODate | |
updatedAt | ISODate |
MessageRole
'user' | 'assistant' | 'system' | 'tool'
Attachment
| Field | Type | Notes |
|---|---|---|
kind | 'file' | 'image' | 'link' | 'project' | |
name | string | |
url | string | |
mime | string | optional |
size | number | optional |
ToolCall
| Field | Type | Notes |
|---|---|---|
id | string | |
skill | string | |
input | Json | |
output | Json | optional |
status | 'pending' | 'running' | 'done' | 'error' | |
durationMs | number | optional |
AnswerMarkKind
── The answer mark ─────────────────────────────────────────────────────────── Where an answer came from, decided by code the model cannot reach. THE PROBLEM IT SOLVES. The Muse already showed its working when it used a tool (ToolCall, and muse/ToolChip.tsx draws the sources) and already carried provenance per memory (twin/provenance.ts). What it never said was the commonest case of all: when it answered from the model’s own weights, nothing said so. “This came from your files” and “this is the model talking” rendered identically, and the difference between them is the difference between a record and a guess. WHY IT IS A FIELD AND NOT A SENTENCE IN content. This is the same shape as signal.raise forcing action: null (packages/skills/src/desktop/index.ts): there, a model that could write both the question and its consequence could author a one-tap approval for anything. Here, a model that could write both the answer and its provenance could author its own credibility. So the two live in different fields. content is the mind’s; mark is the machine’s, computed after the run from what the tools actually returned (apps/api/src/modules/muse/mark.ts). A model is never asked how sure it is, and anything mark-shaped it writes into content is prose — it does not reach this field and cannot change it. Nullable because most messages have no mark and never will: a person’s own message, a system note, a federated line, every message written before this field existed. Null means “not marked”, never “nothing to say” — that case is kind: 'unknown', which is a mark.
'cited' | 'web' | 'reasoned' | 'unknown'
AnswerEvidence
One thing the answer demonstrably drew on. id is a CLOSED shape on purpose — nv:memory/…, nv:file/…, nv:web/… and nothing else. An id pattern that admits arbitrary strings is not a wire format, it is a hole: it would let any future writer of an evidence list invent a provenance namespace, and the point of the mark is that provenance is not inventable. A file’s path and a page’s URL are hashed into the id and kept, readable, in label and url, so widening the pattern is never needed to name a source.
| Field | Type | Notes |
|---|---|---|
id | string (regex) | |
kind | 'memory' | 'file' | 'web' | |
label | string | |
url | string | null | optional · default null |
AnswerMark
| Field | Type | Notes |
|---|---|---|
kind | AnswerMarkKind | |
note | string | |
evidence | AnswerEvidence[] | optional · default [] |
phrasesShared | number (safeint, ≥ 0) | optional · default 0 |
wordsShared | number (safeint, ≥ 0) | optional · default 0 |
wordsInSource | number (safeint, ≥ 0) | optional · default 0 |
Message
| Field | Type | Notes |
|---|---|---|
id | Id | |
threadId | Id | |
authorId | Id | null | null = system |
role | MessageRole | |
content | string | |
attachments | Attachment[] | optional · default [] |
toolCalls | ToolCall[] | optional · default [] |
mark | AnswerMark | null | optional · default null |
createdAt | ISODate |