Buyer conversations
When a buyer messages an account about a Marketplace listing, log stores that chat as a conversation in your Twenty server, tied to the account and the listing. Each message is its own row, so you can see every lead, where it stands, and which listing it came from. Read this if you work with leads or want to know how a chat is matched to a listing.
The shape
A conversation is a row in Twenty's agencyConversations, and every message is an agencyMessages row. These are the same two objects an SMS tool already uses for its threads. log only added fields and relations (scripts/twenty/twentySetup.mjs), so existing SMS rows and readers keep working.
socialAccount 1 ──< agencyConversation >── 1 agencyListing
│
└──< agencyMessageagencyConversation.socialAccount: the account the buyer wrote to.agencyConversation.agencyListing: the listing they wrote about.agencyMessage.agencyConversation: the conversation a message belongs to.
The reverse fields are socialAccount.agencyConversations, agencyListing.agencyConversations and agencyConversation.agencyMessages.
Where the design comes from
| Source | What it gave | What changed |
|---|---|---|
| An earlier Marketplace tool | A thread is a lead: buyer name, id and profile; lead status new → contacted → qualified → booked / lost / spam; per-message read flag, who sent it (person, VA, AI, system), AI draft status and original text. | It keyed a thread on a hash of the buyer's name, so one buyer writing about two listings became one lead. It stored the listing as a title only, and put the current time into message ids, so a rescan stored every message again. Here the key is the account plus Facebook's own thread id, the listing is a relation, and a message key stays the same across rescans. |
| An SMS tool's conversations | One identity per thread (pairKey); a summary (latestDirection, latestPreview of 120 characters, latestMessageAt, latestMessageId, messageCount) that only moves forward; a message status that only moves up received < queued < sent < undelivered < failed < delivered. | The same fields are reused for Marketplace rows, with channel = FACEBOOK_MARKETPLACE. |
| Twenty, before log | agencyConversation (phone numbers, pairKey, latest fields, status, links) and agencyMessage (direction, status, body, numbers, errors). | Untouched, except that a message now links to its conversation. |
Fields log added
agencyConversation
channel:SMSorFACEBOOK_MARKETPLACE. Empty means SMS (rows written before the field existed).facebookThreadId: the id infacebook.com/messages/t/<id>.pairKeyisfacebookMarketplace:<socialAccountId>:<facebookThreadId>.buyerName,buyerFacebookId,buyerProfileUrl,buyerPhone,buyerEmail. Phone and email are taken from what the buyer wrote.listingTitle,facebookListingId: what the thread header shows.leadStatus:NEW,CONTACTED,QUALIFIED,BOOKED,LOST,SPAM.unreadCount,lastReadAt,respondedAt(our first reply),lastScannedAt.autoReplied,aiTookOver.
agencyMessage
channel,sentAt,facebookMessageId,senderName.messageKey:fb:<facebookMessageId>, orh:plus a hash of direction, sender, shown time and text. It is unique within a conversation.isRead,sentBy(PERSON,VA,AI,SYSTEM),aiStatus,aiOriginalText.media:[{ url, kind }].- Reused from the SMS fields:
direction(inbound/outbound),status,body,errorCode,errorMessage.
What recording a thread does
recordThread (src/marketplace/conversation/client.ts) takes one thread as the inbox showed it and:
- Finds the conversation by
pairKey, or creates it asNEW. - Ties it to a listing on that account: by Facebook listing id first, then by title, but only when exactly one listing has that title. If two listings share a title, it stays unmatched and you link it with
log facebook conversation link. - Adds each new message whose key isn't stored yet. Our messages are marked read, the buyer's unread.
- Moves the summary forward only. Our first reply makes the lead
CONTACTED; a phone number from the buyer makes itQUALIFIED.BOOKED,LOSTandSPAMare a person's call, and a scan never sets them.
Recording the same scan twice adds nothing.
Commands
log facebook conversations [--account <name>] [--listing <agencyListingId>] [--json]
log facebook conversation show <id>
log facebook conversation read <id> # marks the buyer's messages read; unread 0
log facebook conversation lead <id> BOOKED # NEW | CONTACTED | QUALIFIED | BOOKED | LOST | SPAM
log facebook conversation link <id> --listing <agencyListingId>
log facebook conversation record --account <name> --file threads.jsonThe --file for record holds one thread, a list, or { "threads": [...] }:
{
"facebookThreadId": "9001",
"buyerName": "Sam",
"listingTitle": "Rubbish Removal Galway",
"facebookListingId": "111",
"messages": [
{ "direction": "inbound", "body": "Is this still available?", "sentAt": "2026-10-19T14:00:00Z", "senderName": "Sam" }
]
}Each command is also an MCP tool (conversations, conversation). None of them opens a browser or writes to Facebook.
Next
- #41 reads the inbox from Facebook and replies to buyers. The scan is a browser launch like any other, with every launch gate, written from a
log inspectrecording of the Marketplace inbox first. A reply writes to Facebook, so it waits until the owner asks for it; its fields are in place already:sentBy,aiStatus,aiOriginalText,autoReplied,aiTookOver.
The rules behind this are in AGENTS.md.