A like is not a relationship: where our agent's permission to reply stops An engineer detailed how their Bluesky agent's auto-reply permission was deliberately kept narrower than its expanded definition of a warm relationship. The agent now treats accounts it has liked or quoted as warm, but still requires human approval before replying to them, while only established relationships qualify for auto-send. The design separates relationship classification from send permissions to keep a public claim of human approval truthful. Our agent replies to strangers in public. The standing rule is that the owner approves every reply before it sends — with exactly one exemption a machine can check: the person is warm , and the draft contains no question aimed at them. Last week we widened what warm means. We deliberately did not widen the exemption. That gap — the relationship definition grew, the auto-send permission didn't — is the part of this design worth stealing. The approval gate was added after a platform engineer publicly asked our account to label itself as a bot. The owner chose a different answer than a label: keep the profile honest by making the claim true — an AI drafts, a human approves. The file header for the gate says why in one line: if the profile claims a human approves replies, the approval has to actually exist, or the profile is a lie. That framing decides everything downstream. The gate isn't there to make the agent behave. It's there so that a public claim and the send path agree. The tempting implementation is to ask the model: do we know this person? We don't. classifyThreadRelationship takes sets of DIDs and returns a verdict, and every set comes from data on disk. Three of them are cheap: accounts we follow fetched live from the Bluesky API, because the API is more truthful about the present than our own ledger of past follows , accounts that follow us, and accounts that liked or reposted our posts in the last 7 days — the last two read off the tail of state/engagement-snapshots.jsonl . The two new ones are the interesting ones, and they live in prior-outbound-touch.ts : collectLikedAuthorTouches walks state/outbound-engagement.jsonl for rows whose action is liked or replied-and-liked . Newer rows carry authorDid directly. Older rows don't — early ledgers stored only the liked post's URI — so the DID is reconstructed with extractDidFromAtUri , which pulls did:plc:… out of at://did:plc:xxx/app.bsky.feed.post/yyy . AT-URIs happen to embed the author's identity, which is the only reason that history is recoverable at all. quotedUri field on our own post log, so loadQuotedPostEvents scans every content/posts/ .jsonl file across all time and treats that field as the source of truth. Rather than add a ledger and start its history at zero, we read the record that already existed. mergePriorOutboundTouches unions the two by DID, tagging each person liked , quoted , or liked-and-quoted , and keeps the earliest touch time. Earliest, not latest, because the same map feeds our conversion measurement — anchoring on a later touch silently drops the conversions that already happened out of the denominator. classifyThreadRelationship deduplicates thread participants by DID and assigns each warm one a single RelationshipBasis from a fixed union: mutual , following , follower , engaged , conversation , repeat-commenter , quoted-by-us , liked-by-us , follow-first . The precedence is explicit in the code — follow relationships beat their inbound reactions, which beat our outbound touches — so a mutual follow never gets reported as "we liked them once." The assessment carries a one-line human-readable summary that ends up in the approval board and in the ledger row, which means every send can be traced back to the specific reason it was allowed. A thread is warm if any participant is warm. Cold means we have never touched anyone in it. Here is the design decision that matters. When the owner's instruction on 2026-08-29 made "someone we liked or quoted" count as warm, the obvious refactor was to let the whole new cohort through the auto-send path. Warm plus no question equals send, and these people are now warm. We didn't, because the exemption came from a different instruction on a different date — 2026-08-27, covering people we have an established relationship with. Nobody authorized auto-sending to someone whose entire connection to us is a like we left on their post. js const BASES OUTSIDE EXEMPTION: ReadonlySet