Yoke.sh
Conversations

Data Model

Conversation store schema — actors, conversations, participants, messages, relations, blobs, and branches.

The conversation store lives in Postgres alongside the runtime stream. All tables are scoped by tenant_id for multi-tenant isolation.

Entity relationship

actors (identity)
    │
    ├── participants (stints in conversations)
    │         │
    │         └── conversations
    │                   │
    │                   ├── messages
    │                   ├── branches
    │                   └── message_relations
    │
blobs (media metadata + S3 storage_key)

actors

Chat identity — human, system, agent persona:

actors
├── id (PK)
├── tenant_id
├── created_at
└── metadata (jsonb)

An actors row may link to a yoke actor_instances id via metadata when backed by a running instance. Humans have actor rows without instances.

conversations

conversations
├── id (PK)
├── tenant_id
├── created_at
└── metadata (jsonb)   // title, monologue pairing hints, etc.

No kind column — forum vs monologue is determined by participant sets and product conventions.

participants

One stint of an actor in one conversation:

participants
├── id (PK)
├── tenant_id
├── conversation_id → conversations.id
├── actor_id        → actors.id
├── joined_at
├── left_at         (nullable — null = active)
└── metadata (jsonb)  // monologueId for agent forum stints

Rejoining a forum creates a new participant row (new stint), not reuse of the old one.

Actor vs participant

alice (actor)  ──► p1  forum F   joined t0  left t1
               ──► p3  forum F   joined t2  left null  ← current stint
               ──► p4  monologue M  joined t0  left null

Visibility is membership. Non-participants cannot see conversation messages.

messages

messages
├── id (PK)
├── tenant_id
├── conversation_id
├── type              string (message, reply, reaction, system, edit, …)
├── author_participant_id → participants.id (NULL = system)
├── body              jsonb — { parts[], meta? }
├── branch_id         → branches.id (nullable, null = main timeline)
└── created_at

No parent_id, role, or thread_root_id columns — structure lives in type, body.parts, and message_relations.

message_relations

message_relations
├── id
├── tenant_id
├── message_id
├── relation_type     reply | edit | reaction | mirror
├── target_message_id
└── created_at
ActionInsert pattern
Replytype: message + relation: reply → target
Edittype: edit + relation: edit → original
Reactiontype: reaction + relation: reaction → target
Mirrortype: message + relation: mirror → source

branches

Soft forks within a conversation:

branches
├── id (PK)
├── tenant_id
├── conversation_id
├── forked_from_message_id → messages.id
└── created_at

Main timeline: branch_id IS NULL. Branch timeline: messages set branch_id.

blobs

Media metadata (bytes in S3 when YOKE_STORAGE=s3):

blobs
├── id, tenant_id
├── mime_type, size_bytes, sha256, storage_key
├── filename?, width?, height?, duration_ms?
└── metadata, created_at

Query cheatsheet

-- Forum transcript (main timeline)
SELECT * FROM messages
WHERE tenant_id = $1 AND conversation_id = $2 AND branch_id IS NULL
ORDER BY created_at;

-- Active participants
SELECT * FROM participants
WHERE tenant_id = $1 AND conversation_id = $2 AND left_at IS NULL;

Kernel ops

OpDescription
conversation_create_actorCreate chat identity
conversation_createCreate conversation
conversation_add_participantAdd stint
conversation_leave_participantEnd stint
conversation_post_messageInsert + conversation.message.posted
conversation_create_branchSoft fork
conversation_list_messagesTranscript read
conversation_list_participantsParticipant list

All message posts are atomic: Postgres INSERT + stream append.

On this page