Guides

logMcpInstructions

Step-by-step guide to the log MCP: every core rule in full, which guide covers which work, and how runs, jobs, browsers and refusals report back.

Written by bun run docs:build from the code, the skills and AGENTS.md (rule 56). Not edited by hand: change the command's usage, its skill or the rule, and rebuild.

Use cases: the work the owner asks for, tool by tool

This is how the owner works. Each use case is something the owner asks for again and again, written as its steps in order, each step naming its tool and what counts as done. When the owner asks for work a use case covers, read that use case and follow it step by step instead of improvising. Work no use case covers yet, done for the owner more than once, gets a page of its own in docs/useCases/<platform>/.

Use caseWhat it isHow the owner asks for it
getGuide({ useCase: "discord/channelReply" })Answering someone in a Discord chat or channel"message the guy in <chat> who just mentioned me", "reply to <someone> in <chat>", "explain what I'm trying to do to <someone> in <chat>", "answer him in the general"
getGuide({ useCase: "discord/groupChatRead" })Reading a Discord group chat without Twenty"read the <group> chat over the last <N> hours", "what did <someone> set up in <group>"
getGuide({ useCase: "discord/memeReply" })Replying with a meme in a Discord chat"respond with memes in the <chat>", "drop a meme in <chat> about that", "read <chat> and meme it"
getGuide({ useCase: "facebook/groups" })Listening to local Facebook groupssee the page
getGuide({ useCase: "facebook/marketplaceListings" })From accounts to live Marketplace listingssee the page
getGuide({ useCase: "inbox/bossMaxxing" })Bossmaxxing: giving out tasks to the team"bossmaxxing", "give out the tasks", "send <person> the task in <group> and tag them", "tell <person> about everything I researched and give them an outline", "triage the jobs across the team", "walk <person> through setting up <tool>", "help <person> get set up like we did with <other person>"
getGuide({ useCase: "inbox/reaLooksmaxxing" })/rea-looksmaxxing: moneymaxx like a creator"/rea-looksmaxxing how clavicular gets paid", "moneymaxx like <creator>", "how does <creator> get paid"
getGuide({ useCase: "inbox/reactions" })Reacting to a message"react to that", "heart his message", "put a 😂 on what she said", "send a reaction to <person>"
getGuide({ useCase: "inbox/voiceNote" })Sending a voice note"send <person> a voice note saying …", "voice note him that …", "send a vn to the group saying …"
getGuide({ useCase: "instagram/dmReply" })Reading and answering an Instagram DM"read my instagram dms", "what did <someone> say on instagram", "reply to <someone> on instagram", "send <someone> a meme on instagram"
getGuide({ useCase: "instagram/newPlatform" })Bringing a new platform up, then vendoring its client"make a new platform via the MCP for <platform>", "open <platform> in AdsPower", "find me via the gh api the most likely to go into the vendor", "make it work"
getGuide({ useCase: "slack/dmReply" })Reading and answering a Slack DM"read the messages with <someone> on slack", "reply to <someone> on slack", "what did <someone> say in <workspace>"
getGuide({ useCase: "twitter/affiliateMarketing" })Affiliate marketing through your DMssee the page
getGuide({ useCase: "twitter/collabMaxxing" })Collab maxxing: turn open conversations into collabs"position a collab first then be like just a few quid so i can make a video about it", "start network maxxing and collab maxxing and trying to see what hes doing get him to reveal it all", "follow up on deepman and the other people, small commitment from deals, not just new people but already open convos", "respond with the collabmaxxing", "selling the outcome", "let him name it", "check in with lior whats good there he is keen but isnt liquid", "just be nice to them there the poor people", "ask ben if i could become a contributor", "look on my twitter for missed opportunities".
getGuide({ useCase: "twitter/dmInbox" })Reading the DM inbox: who is waiting on yousee the page
getGuide({ useCase: "twitter/engagementReplies" })Answering the people who engaged with your postssee the page
getGuide({ useCase: "twitter/engagerDms" })DMing the people who liked or reposted your postsee the page
getGuide({ useCase: "twitter/followUps" })Following up in your own voice"follow up with these, not for anything in particular, just warming up the relationship", "say it was cool to meet him", "ask him for some advice on what he's doing", "take him up on the doc he offered".
getGuide({ useCase: "twitter/followers" })Checking who really follows yousee the page
getGuide({ useCase: "twitter/getTheAction" })Getting people to do the thing: retweet, like, try it"get me all them people who liked it ... yo cool to see that you liked that, would you give that a wee retweet", "notifications: people who like, comment, reply", "go through all the people there who need a message", "get them to retweet the thing", "would you do me a favour and give it a wee retweet", "I need them to retweet it so I can get some inbound leads", "I don't know what they're even talking about, explain the context", "make them do the action I need", "get the full context of everything on the account at the time of sending the message".
getGuide({ useCase: "twitter/managerMaxxing" })Manager maxxing: the managers of the biggest Kick streamers"manager maxxing", "find every kick streamer's manager on twitter", "reply guy the kick managers with memes", "the managers that engaged, send them their tweet, a message and a voice note".
getGuide({ useCase: "twitter/networkMaxing" })Network maxing: grow the network, the niche map"all them people messaging me, just message them something nice, get the ball moving with the networking", "we're not even trying to get anything out of these people, network max", "throw some new people in the mixer", "network max ... look at the nicheMap", "the whole point of it is to expand our network, which is the niche map", "follow them before you cold message them, and like the tweet you send them".
getGuide({ useCase: "twitter/nicheMapRegion" })A new region of the niche map, then outbound to it"for getting accepted into YC ... make a new area of the niche map through the systems we've already made ... the YC world and the funds' world, based on the 26 batch", "the YC stuff in and around the boat.dev people and the people involved in GTM tools", "pull all of my new followers ... set a budget of like 60$", "start with the 43", "not just treg but the actual index that we did already".
getGuide({ useCase: "twitter/notifications" })Seeing who engaged with your postssee the page
getGuide({ useCase: "twitter/recruiting" })Recruiting on X with a reply queuesee the page
getGuide({ useCase: "twitter/replyGuyFunnel" })Reply guy, then the DM, then the ask"reply guy them first and foremost", "send a meme to the tweet then forward the tweet to them as an opener", "message the people who are new to following me", "get it in front of them", "funnel them into doing an action".
getGuide({ useCase: "twitter/topicReplies" })Replying to everyone posting about one topicsee the page
getGuide({ useCase: "twitter/whoNeedsYou" })Finding who needs something from you, or who matters"look through my messages and find someone who clearly needs something off me", "summarise everything on my twitter, how likely is it we make money", "who's worth following up".

Rule one: if we don't know, we do this

This is the number one rule of the whole stack, above every other rule in this file. We already know most of the states a platform can be in. When a step lands somewhere we don't know:

  1. Read the internal API. Every step's internal API calls are taken out of its recording by code: what we sent and what came back, in seconds from our action (exchanges.json, exchanges.md). The answers decide, never the screen, a timer, or the fact that a step ran. What we sent counts as much as the answer (viewer_coordinates placed Bethlehem's search in Lima).
  2. Collate everything about that moment. The goal, the account (home, platform, browser, the proxy's exit), what we did (the action, the mouse, how long it took), every internal API call, the page, the screenshot and the earlier steps, together, never in silos.
  3. Ask SubQ what it means. Not what to do next: was the goal met, does everything fit the account, should something never have come up, does the page agree with the API, did our action cause it, and what each call says about the goal.
  4. Say it loudly. The reading comes first in the step's result (>>> UNKNOWN STATE) and goes into the agent's context through the hook.
  5. Keep going until the goal is reached. The Stop hook holds the turn until a step's reading says the goal is met.
  6. Write what went wrong as P.E.E. Point (the issue, one sentence), Evidence (quoted from the step's reading), Explain (why it shows it, what caused it), and, once the goal is reached, how it was fixed (log inspect finding, goalFindings.md).
  7. Name the state, so next time it is known and nobody has to find it out again.

It works the same on every platform: every platform's client triggers internal APIs nobody documents, and our job is to find what our action caused. It runs in log inspect (--goal, inspect goal, inspect finding, inspect read), where flows are made to be run again and again.

  • Why: on 2026-10-22 Bethlehem's warm-up loaded /marketplace/create/item twice; its HTML said sell_location: Barranco, 15063, PE both times, and the warm-up passed. Then "Bethlehem, PA" saved the West Bank, and every step said ok. Read this way, SubQ said wrong result (93%) and pointed at Lima and the West Bank in Facebook's own answers.
  • Status 2026-10-23: built for log inspect (PR #36). The page states marketplaceCreateLimitReached and marketplacePartnerFeed are decided by Facebook's answers. Every warm-up step that makes a call is decided by its answer: the five stage flows (the create page's marketplace_new_seller_can_post_item and sell_location, buy_location, the feed's account, paging, listings) and the Reels and feed activities, likes included, in log flow run, the stage runner and the host. The publish flow checks the create page and the listing id in the create answer; the profile flow its three saves; every other flow step that waits on a call checks its answer (delete, comment, posts, the Messenger PIN, the group tabs and share). On AdsPower (the account's browser: inspect, flow, warm-up, host, and join, read, poll and check in it) the host's account check reads Marketplace status from its answer, and join, read, poll and check already decided on theirs. Only the host's sign-in still decides on Facebook's sign-in cookies and the URL. The Camoufox-only commands aren't the primary path (#35).

Rules 73-76.

The core rules, in full

The log tools run log-cli: Facebook accounts (Twenty socialAccounts) and what they do on Facebook (Marketplace listings, groups). Its rules are matt's, in AGENTS.md (read the resource log://agents; the newest rules are at the bottom and win). The core of them:

  1. Rule one, above every other: if we don't know, we do this. Read the step's internal API (what we sent and what came back; the answers decide, never the screen, a timer or the step having run); collate everything about the moment (goal, account, what we did, every call, the page, the screenshot, the earlier steps); ask SubQ what it means; say it loudly; keep going until the goal is reached; write what went wrong as P.E.E. (point, evidence, explain) with how it was fixed; and name the state so next time it is known. In inspect: goal, inspectFinding, inspectGoal, inspectRead (rules 73-76).
  2. When matt says to do something, do it completely, in the same turn: not stopped to ask again, not narrowed to what you think (rule 30).
  3. Only what matt said: never add options, scope, context or spending he didn't ask for; paid work does exactly what he said (rules 39-41).
  4. Never mention or discuss Meta's or Facebook's rules, terms or account ownership with matt unless he raises them (rule 38).
  5. Read-only on Facebook unless matt asked: anything that posts, joins, answers, clicks or edits is its own …Live tool (publishLive, inspectDoLive, hostStartLive, joinLive, warmupLive, flow…Live), annotated destructive, and needs confirmLive: true; every other tool never acts on Facebook and refuses live (rules 8, 10). Outside an account's local hours only with --bypass and confirmBypass: true, when matt asked (rules 18, 20).
  6. One browser per account for its day, every action a step in it: open it with inspectStart (account, detach: true), never a launch per action; at most 8 opens a day, 10 minutes apart (rules 24, 35).
  7. Every browser opens through its gates: proxy in the account's country, the self-check (its languages must start with the pinned ones; extras after them are allowed), headed, recorded (rules 11, 12, 17, 22, 92).
  8. A run that fails is fixed from its own recording (its HAR read with rea), never by guessing; code that clicks a page is written from a recording of that page (rules 25, 29).
  9. matt sees everything as it happens (rule 26). Before any call that takes more than a few seconds (a browser open, a join, a fill, a publish, a capture), start a Monitor on its live log, tail -f ~/.logCli/mcp/<day>.log (UTC day, YYYY-MM-DD), so every line shows in the client while it runs, and say in plain words what each stage means. Never wait silently for the result. A client without a shell (a claude.ai connector, Duet) reads each result's progress (every streamed line, timed) instead. Every result also says where every open browser is (browsers), the call that resolves its known page state (next: make it, do not stop, rule 54) and what only matt can clear (needsMatt); browsers is a tool too. A long run that outlasts its call's wait (waitMs, default 20 s) comes back as a job (exit -1, job.id): follow it with jobStatus { jobId, afterLine } until it ends, stop it with jobCancel (at its next browser step, never past Publish, rule 10); one job holds an account at a time (accountBusy, rule 35).
  10. What a platform's client sends is documented per platform, then domain: docs/platforms/<platform>/<domain>/<request>.md (Facebook: docs/platforms/facebook/, groups in groups/), built by capture --docs from the runs' recordings (rule 27). A new platform or domain is explored with inspect first and gets its own folder (rule 29). Every platform's calls go through rea, their code as well as their network: capture --docs finds each call's code in that platform's own bundles by syntax tree and rea reads it, never Facebook's alone (rule 85).
  11. Accounts live locally, in ~/.logCli/localAccounts.json (localAccount add, pull, list; LOG_ACCOUNTS=local in .env.local), each pulled one keeping its Twenty id so listings and groups still find it; listings and groups live in Twenty: never delete a row, fields are only added (rules 13, 14, 97).
  12. Names are camelCase (rule 1) and say what a thing does, never which browser runs it (rule 46). Groups are their own library (rule 45). The warm-up is by platform and activity (facebook warmup reel): each activity's steps say what they must do, and every run outlines the ones that didn't (rule 49). The Reels warm-up with --persona builds the persona's For you feed: SubQ reads each reel from its frame (theirs or not), and the action orderer plans it from the persona pack (every dataset a trait) and its real KuaiRand-1K viewer, longer and more engaged on their kind as measured on real viewers, one action per reel, only what the run allows (rule 50). Proxy traffic is money: each account has a 24-hour traffic budget (1 GB), checked before and during a warm-up and written to ~/.logCli/proxyTraffic.jsonl (rule 51). Proxies are provider-agnostic: Twenty keeps one proxy URL per account from any provider (2Captcha, Byteful); proxy assign --vendor byteful checks before it saves, and log-cli never buys (rule 52). Anything that has to be found (a group, a provider, a fact) comes from an external search, TinyFish through treg, never memory; a suggestion names its source (rule 53). A known page state (a checkpoint's first page, a text captcha, a prompt, the login form) is resolved, never a reason to stop: press its Continue, solve the captcha; only a video selfie, ID or disabled account goes to matt (rule 54). The agent's hooks (Claude Code and Codex, one list) say where every open browser is after each call and keep the turn going while one sits on a state log-cli resolves; the terminal CLI writes the same live log as these tools (rule 55). Every tool's description carries its command's full usage, the same the README lists (rule 56). What every browser uses is under src/browser, never named for one browser (rules 46, 57). No loose files: python/ and scripts/ hold domain folders only (rule 58). log-cli is browser-agnostic, AdsPower first: a command runs on the account's browser through the driver, or on Camoufox only while it isn't on the driver yet (rule 59). It is platform-agnostic too: Facebook is the first platform, every command declares its platform, and what is scoped by platform names it in every form (warmup facebook reel|feed|stages, never warmup run) (rule 60).
  13. Facebook group listening is obris-dev/openmagpie#188's triage job: an account joins (facebook join group), OpenMagpie polls with facebook poll --request (a read in the account's open browser), keeps every post, triages them with its watch, and sends only the matches to Twenty's socialListening. log-cli writes only the group's cursor to Twenty; facebook read group (readGroup) reads by hand and saves nothing else (rule 65). facebook read members (readMembers) reads a group's newest members (name, profile, bio, page category, followers, when they joined) from its People tab in the account's open browser, nothing clicked: it says up front count N → ~S scrolls, ~M min at Xs per scroll, waits secondsPerScroll (default 15) before each scroll, stops at count, the list's end or maxScrolls, owners keeps owners (a bio naming owner, co-owner, founder, CEO, president, proprietor or self-employed, or a page), and the members go to ~/.logCli/members/<group>/<run time>.tsv and .json, never to Twenty. A members page whose refetch answered with members is the known state groupMembersList: nothing to resolve (rule 77).
  14. The platform comes first in every command that acts on one: log <platform> <command> … (log facebook connect, log facebook warmup reel, log facebook poll <group> <account> <how long to poll>); there is no form without it, and an old form is refused with the one it became. Most tools are one form of a command each (inspectStart, inspectDo, accountShow, runsPrune…) and take named fields, each value passed whole; the rest take args, what follows the command (the warm-up's start with its platform) (rule 63). The code is laid out the same way, src/<platform>/<area>/<domain>: src/facebook/group/…, src/facebook/warmup/…; what any platform uses stays shared (rule 64).
  15. Code carries no comments and no docstrings: names, types, tests, skills and docs say what it does; check:noComments refuses a commit or push with one (rule 70).
  16. An ad on more than one listing in a city carries copy versions (copyVersions, written by skills/listingCopy): at least as many as its live listings there, never fewer than 3, titles differing by half their words; each copy takes the one least like what's live; listing lint checks them, campaign fill refuses too few (rule 71).
  17. A Marketplace location is typed by name, never abbreviated: "City, State" with the state written out ("Bethlehem, Pennsylvania") or "City, Country". An abbreviation ("Bethlehem, PA" was offered only as the West Bank), a zip or a number (a bare zip offered Granada, Spain) is refused with locationAbbreviated before anything is typed: in a listing's locationQuery, inspectDo setLocation, a flow's location, publish and the host (rule 72).
  18. In inspect, every step reads its internal API calls (what was sent and what came back) into exchanges.json; a step no known state names is read by SubQ with the goal (goal), the account, what we did, every call, the page, the screenshot and the earlier steps, for what it means. Its reading comes first in the result (>>> UNKNOWN STATE): name that state before the flow goes on. inspectRead reads a recorded step again (rule 74).
  19. An inspect session has a goal (goal on inspectStart, or inspectGoal with set), and the turn is not over until a step's reading says it is reached. A step that went wrong gets its P.E.E. finding before the next step, with inspectFinding: point (the issue, one sentence), explain (why the evidence shows it, what caused it); the evidence is quoted from the step's reading. Once reached, each finding says how it was fixed (fixes, fix). What only a person can clear is said to matt (rule 75).
  20. Flows are deterministic: a control is one scored match by role and name (accessible name, aria-label, label, text or placeholder, case ignored) inside the dialog the step names (within), never a guess (controlAmbiguous, controlMissing, controlDisabled); every step waits for the call it must cause and checks its answer before the next fires; before its first click a flow clears the known overlays (the browser-notifications notice, an ended "What happened" notice) with Escape, each checked, or stops with overlayNotCleared. Run preflight before opening accounts: it says what would stop each one (timezone, hours, proxy, kernel, opens left, a session left open, its picture) and --apply fetches a missing kernel (rule 79).
  21. X (Twitter) is a platform of its own, log twitter <command>, on the direct-API engine only: twikit (vendor/twikit) with the account's saved cookies from Twenty, no browser and no proxy. X accounts call X directly, as twitter-controller does. X's own answer decides (socialAccounts.sessionState), each account has its own governor, and refreshed cookies are merged back (rule 80). We maintain vendor/twikit ourselves: its transport is curl_cffi (impersonating Safari), never httpx (check:twikitTransport, rule 81). When matt says to send anyway, force goes past the account's governor (its pause and caps) for that call or run only; X's answer still decides (rule 86).
  22. A new platform is made whole with log new platform <name> (newPlatform): its session, log <name> session check, its engine and docs, and its spec with SubQ questions and anchors (docs/specs/<name>.md, src/specs/<name>), wired into the repo through the syntax tree; a dry run until apply, and check:platforms refuses one half wired (rules 83, 124).
  23. inspect and the account's browser work on every platform with a browser path: PLATFORM_SITES says its hosts, home and whether it needs a proxy. Facebook always does (rule 12); a platform marked no-proxy (Reddit) opens direct on this machine's exit (rule 84).
  24. The knowledge base is in ListeningKit's Twenty and searched through Redis: before guessing at a rule, a skill, a page state, an account, node02 or what an earlier session learned, search it with kb search (keyword and meaning at once); save what a session learned with kb remember; kb sync refreshes it from the repo, the memory files and log-cli's Twenty. Nothing secret is ever written to it (rule 93).
  25. Never act on an account in quick succession: every platform call goes through log-cli's own commands and their governor, never a hand-rolled script with an account's token or cookies; a 429 or a failed send stops the run with nothing retried and the next action waits for matt; a bulk read of a server, group or member list is its own session, never in the same one as sending; cold DMs go out a few a day, minutes apart, each new DM channel counted as a write; after a block, disable, captcha or cooldown nothing resumes on that account until matt says so. One person's X Chat is opened as X's app opens it (twitterDms with with), never by reading the whole inbox (rule 94).
  26. The work matt asks for again and again is a use case (docs/useCases/<platform>/<page>.md): how he asks for it, then its steps, each naming its tool. When he asks for work a use case covers, read it with getGuide({ useCase }) and follow it step by step, never improvise; each tool names the use cases that call it. The pre-commit review asks SubQ whether this session's work left a page stale or uncovered (rule 95).
  27. An X account makes up to 240 writes a day (sends, likes, reposts, follows), Discord 120, every other platform 40; X's own answer (344) still stops it (rules 96, 101, 122).
  28. A first message to someone matt has never talked with is the opener only: about them and what they posted, always relevant, never the ask (no pitch, no "you using treg", no call); the ask waits until they answer, and voice fails a first message with one (rule 98).
  29. Network maxing grows matt's network, which is his niche map: new people come from it first (real roles, closest first, not yet talked to), and every message is about them and what they're doing, never what he sells (rule 99).
  30. A reaction to matt's message, or their read receipt past it, means they opened it: twitterDms { waiting: true, limit: "200" } lists it apart as a follow-up to send (about them and what they're doing, never the opener again, never the ask), not a reply they're waiting on (rule 100).
  31. LinkedIn is reached only through vendor/openLinkedinApi's one curl_cffi Session pinned to Chrome (IMPERSONATE), never plain requests, and every call goes through the account's governor; a LinkedIn account writes at most 10 times a day (rule 102).
  32. More than one send on an account goes through the queue (twitterQueueBuild, plan, run), whose random times space them like a person; never a hand-rolled loop of sends and never helpers reading or sending on the same account in parallel. The X engine takes one call per account at a time (engine.lock, accountBusy after 15 min), spaces each send 4 to 11 s after the last call and each read 0.8 to 2.5 s (rule 110), and keeps the transaction key for 3 hours instead of loading x.com before every call (rule 104).
  33. One X session per account, always: every X call (CLI, MCP, hooks, queue, X Chat watch) goes through that account's one engine host (python/twitterApi/host.py), which holds one twikit client, one connection and one transaction key, as X's own web app holds one page. A second session can't start (host.lock); if the host can't be reached the call is refused loudly (xSessionHostDown) and nothing is sent. Never open another session or hand-roll a script to get around it. The hook's account line shows the open session (rule 105).
  34. Every log action goes through these MCP tools, never a hand-run log … or bun bin/log.ts … from a shell (the shell hook refuses one); only the terminal-only prompts are run in a terminal, by matt. Before changing log-cli code, read what is already changed and uncommitted (the hook says it loudly: >>> UNCOMMITTED LOCAL CHANGES) and never overwrite, revert, stage, publish or ship someone else's uncommitted work without matt (rule 106).
  35. An account has one queue and one run for everything it sends: dms, reply-guy replies and answers together. Put more in with twitterQueueAdd (even while it runs: each is fitted between what is planned, minGap apart) and keep it going with keepOpen on twitterQueueRunLive; never a second queue or run for the same account (rule 107).
  36. If something isn't working, fix it: never report a broken thing to matt as a fact and stop. Find the cause and fix it in the same turn; only when the fix needs something from matt, say exactly what (a restart, a login, a key, a yes). Nothing runs stale: the hooks clear dead queue locks and restart inbox watchers and X session hosts that run older code than the checkout. When someone answers matt, a reply that answers them and fits that conversation is sent straight away (matt gave full authority); only a meeting, a promise, money or a conversation you can't place waits for him. Times are said in matt's own clock (rule 108).
  37. Full context at the time of sending, every time: before drafting to anyone read twitterDms { with }, which gives their whole thread (including what matt sent from outside log) and what they did on the account's posts (likes, retweets, replies, quotes, mentions, follows); someone who already did the action is thanked, never asked. The queue reads it again right before each send and holds the item when anything changed since the draft; redraft it and put it back with twitterQueueEdit, never by editing the queue file (rule 109).
  38. Reply only to people with 1500 followers or fewer: an account that is too large isn't worth it yet. Pick reply-guy targets from the niche map under 1500 followers; the engine skips a larger author (authorTooBig) before liking, following or sending, and the queue goes on (rule 121).
  39. Every platform gets the live websocket hooks X and Discord have: its account's one session holds the platform's own push channel open (X Chat, the Discord gateway, Slack's wss-primary), the inbox hook starts that watcher and puts each new message in front of the agent (>>> NEW MESSAGE), and a platform isn't done until it has one (rule 123).
  40. Slack is reached only through vendor/gabrielSlack's one curl_cffi AsyncSession pinned to Chrome (IMPERSONATE, chrome150) with that target's own user agent, for its web API and its websocket alike, never httpx or websockets (check:slackTransport, rule 125).
  41. Audio is detected once, in the shared message layer (python/platformKit/media.py, src/platformKit/media): every platform keeps its messages' attachments, and every voice note goes through one AssemblyAI path (transcribe), kept by its file's hash and written into the conversation store as (voice note: "…") (rule 126).
  42. The voice gate also measures matt's own phrasing: a draft whose word pairs he has written fall below his own 10th percentile fails, with the pairs he never wrote named ("is wild"), and the vendored ai-watermark-scrambler (layer A) reads every draft for invisible or lookalike characters; both go to SubQ (rule 127).
  43. Instagram is worked on the direct-API engine only, vendor/instagrapi over the MCP: instagramDms reads, instagramSendLive sends text and photos (a meme goes as photo), instagramWatch holds Instagram's realtime connection and the inbox hook puts each new DM in front of the agent. Never the account's browser for an Instagram action; a missing action is built on the engine (rule 128). Every Instagram call goes through the account's one session host: one client signed in once, one realtime connection, never a second process signing in (rule 129).
  44. A message sent one line at a time stops the moment they write back: before each line after the first, the send reads what the push channel put in the inbox for that chat since it began, and if they wrote, the rest is held and the answer leads with >>> INTERRUPTED (what they said, the lines not sent). Answer them first, never resend. Discord, Slack, X dms (and the queue's) and Instagram alike; keep a Monitor on ~/.logCli/inbox/pending.jsonl so each push wakes you (rule 130).
  45. One watcher process per account per platform, never two: every Claude session shares it through the inbox (pending.jsonl). Never start a watch of your own while the inbox watcher holds the account: it is refused with watcherAlreadyRunning. A >>> ONE PROCESS PER ACCOUNT, NEVER TWO alert means a second one tried to start; tell matt at once (rule 131).
  46. Every platform has one owner per account and everyone else attaches: the first watch is the owner (it holds the connection, writes the inbox and session.json with a 5 s heartbeat, and relays every event); any later twitterChat watch, discordWatch, slackWatch or instagramWatch attaches to that owner's relay and streams the same events without opening anything. An owner quiet for 60 s is stopped and alerted, and the next start takes over (rule 132).
  47. Only the session that claims a chat acts on it. Every pushed message carries an event id; before drafting a reply, claim its chat with inboxClaim (10 minutes, renewed by claiming again). Every send claims the chat itself and releases it once sent, interrupted or failed, and a send into a chat another session holds is refused with chatClaimedElsewhere: leave it to that session. Read inboxActions for the chat first to see what was already done there (rule 133).
  48. The inbox lives in one SQLite database, ~/.logCli/log.db: the owner appends every event once with a sequence number and each session reads from its own cursor; owners, claims (each with a generation, checked before every part of a send), actions, alerts and stage timings are rows in it, and an event type nobody catalogued is stored and raised loudly. Never read or write the old inbox files by hand (rule 134).
  49. Missing context is filled by catching up, never by re-reading the platform: each owner keeps the platform's last position, a gap (a restart or a dropped socket) marks the account behind, and one catch-up job in the owner pulls only the delta through the one session. A send waits for it and is refused with accountBehind if it can't; a refused catch-up stops everything on that account until matt says so (rule 135).
  50. The context right before a send comes from the .db, not from X: messages both ways and what they did on the account's posts, once the account has caught up. Only catch-up, backfill and discovery read the platform (rule 136).
  51. The voice gate runs inside every send (discord, slack, twitter dm, the queue, instagram), whether or not voice ran first: SubQ's read, the AI tells index with its reranker, matt's own phrasing, the watermark checker and the scrambler, against this chat's context from the .db ranked for the moment. A failing draft is refused with voiceGateFailed (held in the queue): rewrite it from what failed. Only embeddings are stored; results are fresh each send (rule 137).
  52. Media is read before anyone replies: one local vision model (LFM2.5-VL-450M, kept warm in one describer per machine) describes every image, meme and video into the .db, and anything not described yet is shown so in the context; never answer a message whose media says not described yet as if you had seen it (rule 138).
  53. Reacting to a message is a write like a send: twitterReactLive, discordReactLive, slackReactLive or instagramReactLive with the chat and the message's id from the platform's read, the emoji (or its name) and remove to take one off; it claims the chat first, the platform's answer decides, and a refusal stops (rule 140).
  54. A voice note goes out only when matt asks for one, in his cloned voice, as the platform's own voice note: discordVoiceLive, instagramVoiceLive, slackVoiceLive or twitterVoiceLive with the chat and text in his words (never an audio file). It is gated like any send, said by the voice provider from his clone (cloneVoice once per provider, cloneSpeak to test), converted with ffmpeg, claimed, and the platform's answer decides; on X it goes as an X Chat voice message, opened with the saved PIN (rules 142, 151). The voice layer is provider-agnostic: provider (inworld or fishAudio), else LOG_VOICE_PROVIDER, else inworld (rule 143).
  55. When matt dictates the exact words of a voice note, send them with mattsWords: the voice gate's read, which grades speech as typed text, is skipped, and the AI artifact check still runs. Words the agent drafted are always gated (rule 144).
  56. matt's voice is cloned in his Irish accent from his own Discord voice note (LOG_VOICE_ACCENT, LOG_VOICE_LANGUAGE en-IE, en-GB if Inworld refuses it), spoken on inworld-tts-2 at LOG_VOICE_SPEED (1.15); cloneVoice with apply and replace makes it again and keeps the old voice id under previous, from clones from recordings of his he names (rules 145, 146).
  57. Every voice note is shaped like matt's own voice notes (his room tone from his own notes, phone squash, mic drift, saturation, breaths) and listened back with AssemblyAI before it goes: a lexicon word said wrong or spelled out is refused spokenMismatch, an acronym the lexicon doesn't have is refused pronunciationUnknown before it is spoken, and one that can't be checked is never sent (spokenCheckFailed). Rewrite the word the way he says it; never add a lexicon entry he hasn't said (rule 147).
  58. When matt asks for a voice note in another language, send the translation with translationOf (the original words, which the voice gate grades, since it can't match a translation to his writing) and language (zh-CN, es-ES ...), the language the clone speaks it in. Only for a translation he asked for, never with mattsWords (rule 148).
  59. A voice note is written at the length matt asks for (a 40 s note is about 100 to 110 words), from the whole conversation: take what they are talking about further, never re-explain what matt already told them, and don't make every note three short lines. Its gate grades it as spoken (voice with spoken, as every voice note send does), never on length, and refuses one that doesn't build on the chat (buildsOn). A note never opens with a breath, and its breaths and pace vary (rule 149).
  60. On X a voice note is twitterVoiceLive { account, with, text }: an X Chat voice message sent the way X Chat's own app sends one (encrypted m4a, uploaded to the conversation's media store, one AUDIO attachment), through the account's one session with X Chat opened by the saved PIN; with no PIN it is refused xchatPinMissing (rule 151).
  61. When matt says to send a dm the voice gate refused ("send it anyway"), twitterDmLive with overGate sends that one message: the gate's read is skipped and the AI artifact check still runs. Only on his word for that exact message, never on your own and never in a queue (rule 152).
  62. Cold outbound on X is the opener (their tweet forwarded, one line about it), then a brief voice note in matt's cloned voice: both go through the account's one queue, the voice note as a voice item added once the openers have gone, gated as speech (rule 156).
  63. Name the chat in every Discord, Slack or Instagram call (a person, a group, or a server channel as "<server> #<channel>"), never its id: a hook refuses an id. Every send and voice note is gated against the room it goes to, so a channel where nobody else has spoken lately is talked to as a whole (rule 155).
  64. Never show matt a person as an id, on any platform: name everyone by their username (and display name), looking an id up through the account's own session first; one that can't be named is said with why, never as the bare id (rule 154).
  65. To send text into an X Chat group, twitterDmLive takes the group's id (g and digits) as userId; X Chat is opened with the saved PIN, and with none it is refused xchatPinMissing. twitterVoiceLive takes the same id as with (rule 153).
  66. A catch-up refused on an account (accountBehind) stays refused until matt says to resume: then inboxResume { platform, account, by: "matt" } opens its gap again and the owner catches up within 30 s. Never resume on your own (rule 150).
  67. Load the use case for the work before any send: getGuide({ useCase }) makes it this session's play, and every answer after ends with the step you are on and the next one; a send as matt with no play loaded is refused with playMissing. No two messages read alike: a send half made of word pairs already sent to someone else in the last 24 hours, or a queue item that repeats another, is refused with sameAsSent, even with overGate. Write each one from that person's own thread (rule 157).
  68. When a >>> PLAY MATCH names a use case for matt's message, load it with getGuide({ useCase }) before anything else; every use case is also a skill log-<platform>-<page>. When a draft is refused, rewrite it from evidence, never memory: voice with no text for that person's thread and matt's real replies, search for what he said on it before, then a draft unlike anything sent today (rule 158).
  69. A >>> MAYBE A PLAY lists the 3 plays closest to matt's message: before any tool, say in one line which one he is asking for, or none (building, fixing or talking about the system is none). A >>> ACTIVE PLAY after compaction is the play this session was in, with its steps done and to do: load it again and carry on from the next step (rule 159).
  70. A read for one chat or person (discordRead, twitterDms { with }) answers from the .db when its owner is live and the chat is backfilled; the platform is read only to fill the .db once, for a server channel, or when no owner is live. Never re-read a whole inbox to find one person, and never read around a refused catch-up (rule 160).
  71. A message that hands someone a task is a brief, never a curt order: send it with brief so the gate asks whether it says what to do in order, why it matters, what done looks like and by when, kindly, and how to ask when stuck. Files (a guide, a sheet) go with it through file, from OUTBOX or ~/.logCli (rule 162).
  72. Every call is timed, never guessed: each tool call gets a call id (on its ▶ and ■ lines in the live log, with how long it took), and every stage inside it is stored in log.db (guards, the voice gate, each fetch, the Python start, the session host's queue, lock, spacing and governor, each platform request). Before saying something is slow or fast, or changing code to speed it up, read timing (p50, p95 and p99 per tool and per stage) or timing { call } for one call's waterfall, and measure again after the change (rule 165).
  73. treg is an extension: find an endpoint by the job with tregSearch, read its price and params with tregGet, and call it with tregCall (params as a JSON object), the key injected server-side by treg. A free endpoint runs; a priced one is a dry run that says its price, and runs only with tregCallLive { approvedBy, confirmLive } when matt asked for that spend. Every lookup comes from it, never memory (rules 41, 53, 166).
  74. The log MCP server reloads itself when log's code changes: never tell matt to restart Claude Code for new code. The reload waits for any call or job to finish, and a call that comes back logMcpRestarted is simply called again. A tool added since the session started may not be listed yet: reach it through logSearch, logGet, logCall (rule 167). A refusal names its error code and what to do (errors tool). Each tool's description ends with the AGENTS.md rules it falls under.

Which guide for which work

Each tool's description names its guide. Load it with getGuide({ topic }) before first using that tool.

TopicWhat it covers
logCatalogFinding and running any log command with three tools: logSearch by what you want done, logGet for its inputs and a call that works, logCall to run it
facebookBrowserAn account's one browser for its day: preflight, opening and signing in, inspecting a page step by step, the host and its lifecycle, the profile and Messenger PIN, and the account checks
facebookFlowsRecorded flows and the warm-up, run as steps in the account's open browser, each step checked against the call it must cause
facebookMarketplaceMarketplace listings from copy to publish, campaigns that fill and heal them, and the buyers' conversations
facebookGroupsFacebook groups: joining and answering their questions, reading posts and members, and serving OpenMagpie's polls
xInboxReading an X account's DMs and X Chat as one inbox, synced by sequence id, and watching X Chat live
xSendingSending as an X account: original posts with a video or images, dms one message per line, replies, quotes, likes, follows, queues, and matt's voice gate before every send
xAccountAn X account's session: saving and checking its cookies, who follows whom, and its notifications (who liked, reposted, replied to, quoted or followed it)
discordA Discord account: its session, chats, reading, the live gateway watch, sending and deleting its own messages
redditA Reddit account's session
slackA Slack account: its session and workspaces, chats, reading, the live websocket watch and sending
linkedinA LinkedIn account's session: saving its cookies from a masked prompt and checking them with LinkedIn
accountsAndProxiesAccounts in Twenty, their status and secrets, their proxies, and the interactive launcher
runsAndEvidenceWhat runs leave behind: run folders and pruning, captures and request docs, error codes, jobs, every call's timings and the MCP server itself
knowledgeAndSearchFinding context: the knowledge base, the conversation search (keyword and meaning at once) and every voice note transcribed into it
extensionsExtensions that make an account more human on social media: Memelord's memes found for the moment in a chat and sent in context, and matt's cloned voice (Inworld or Fish Audio) for the voice notes he asks for, every paid provider's balance, and treg's catalog of endpoints for every lookup
newPlatformMaking a new platform whole with one command, and where every new file goes
instagramAn Instagram account: its session, reading its DMs, sending text and photos, and the live realtime watch

How a run reports back

  • With a shell (Claude Code, Codex): Runs for seconds to minutes: start a Monitor on tail -f ~/.logCli/mcp/<day>.log before calling, so matt watches it live (rule 26). A run that outlasts the call's wait (waitMs, default 20 s, at most 50 s) comes back as a job (exit -1, job.id): follow it with jobStatus { jobId, afterLine }, stop it at its next browser step with jobCancel.
  • Without one (claude.ai, Duet): Runs for seconds to minutes: every line it streams comes back in the result's progress (with its time), and the result says where every open browser is (browsers, next, needsMatt) (rules 26, 54). A run that outlasts the call's wait (waitMs, default 20 s, at most 50 s) comes back as a job (exit -1, job.id): follow it with jobStatus { jobId, afterLine }, stop it at its next browser step with jobCancel.
  • Every result also says where every open browser is (browsers), the call that resolves its known page state (next: make it, don't stop, rule 54) and what only matt can clear (needsMatt).
  • A refusal names its error code; the errors tool says what each one means and what to do.

The work, step by step: logCli

From skills/logCli/SKILL.md.

logCli

Run from the repo root (REPOS/log-cli). Every command takes --json, and none of them prompt unless you run log launch in a terminal.

Hard boundaries

  1. Read-only on Facebook, except log facebook publish --live. scan, status and watch only read. connect only saves cookies. publish is a dry run unless --live (AGENTS.md rule 10). Nothing is edited or deleted on Facebook.
  2. Twenty is the source (rule 13). Accounts are socialAccounts (FACEBOOK), listings are agencyListings. Never delete either: banned or dead accounts keep their record (log facebook account status). Never print cookies, proxies, passwords or 2FA secrets; log never selects password, email or totpSecret, except connect --noPrompt's auto-login (rule 21).
  3. Marketplace only. No X, Reddit, keywords or communities.
  4. Camoufox is always visible. Every launch is headed, with humanize on and the cursor shown (AGENTS.md rule 11). There is no --headless. Run it where a screen can show the window.
  5. No browser without the account's proxy (rule 12). The proxy is the socialAccount's proxy, its country homeCountry. No exception: not dry runs, not connect. docs/proxies.md.
  6. One device, one locale, checked, in local hours (rules 15-18). An account's first launch makes its device (deviceProfile in Twenty), and every launch replays it. The locale comes from HOME_COUNTRIES. The gate's self-check closes the browser before Facebook if the timezone, locale or exit IP is off. Nothing runs outside 08:00-22:00 in the account's home timezone (activeHoursStart/activeHoursEnd to change it). --bypass skips the hours for one run when matt asks (rule 20), and nothing else. From Vietnam, Ireland's day is 14:00-04:00 ICT. Never clear deviceProfile without matt; after a Camoufox upgrade, re-run the live leak checks (docs/proxies.md).

Environment

  • Bun, Python 3.12 with facebook-camofox-client installed (pip install -e ../facebook-camofox-client), and the Camoufox browser (python -m camoufox fetch).
  • log-cli/.env.local (git-ignored): TWENTY_BASE_URL, TWENTY_API_KEY, CAPTCHA_KRAKEN_API_KEY, 2CAPTCHA_API_KEY. LOG_PYTHON picks the interpreter.
  • After adding Twenty fields: node scripts/twenty/twentySetup.mjs --apply, then bun run twenty:client (the generated client, rule 14).

Commands

bun run launch                                          # Clack menu over everything below

## Twenty data
log facebook accounts                                  # FACEBOOK accounts, and whether each may run
log facebook account show galwayOne
log facebook account status galwayOne BANNED --reason accountDisabled   # kept; campaign listings replaced
log facebook listings [--status READY] [--account galwayOne] [--campaign <id>]
log facebook listing <agencyListingId>
log facebook listing create --account galwayOne --file ad.json   # a READY row, checked like publish checks it

## sign in to log itself (Twenty OAuth through blaster's web app, same as `blaster login`)
log login | whoami | logout

## proxies (rule 12): check before anything else
log proxy check --account galwayOne           # exit IP + country, no browser, saved to Twenty
log proxy assign --account galwayOne          # a 2Captcha proxy in its home city, checked, then saved
log proxy assign --missing                    # every FACEBOOK account without a proxy
log proxy assign --account lancasterOne --region pennsylvania   # pin the state; exits over 100 km from home are retried, then proxyTooFar
log proxy balance                             # 2Captcha traffic left

## live (launches Camoufox through the proxy)
log facebook doctor                                    # setup check, which accounts may run
log facebook connect --account galwayOne               # the connect machine; a person signs in in the window, cookies saved to Twenty
log facebook connect --account galwayOne --noPrompt    # the same, auto-login typing Twenty's details (rule 21), up to --wait 600 s
log facebook scan --account galwayOne                  # Marketplace > Selling, written back to the listings
log facebook status <facebookListingId> --account galwayOne

## the monitor (XState), every step printed live
log facebook watch --account galwayOne [--checks 0 --interval 240] [--json]

## publish one Twenty listing (XState), every step printed live. Dry run unless --live
log facebook publish --listing <agencyListingId>
log facebook publish --listing <agencyListingId> --live
## against the local copy of the create form (no Facebook)
log facebook publish --listing <id> --formUrl file:///$PWD/python/fixtures/createItem.html

## campaigns: one ad on N accounts in one place (docs/accountsAndListings.md)
log facebook campaign fill --campaign <id> --from <agencyListingId> --accounts 10
log facebook campaign run --campaign <id> [--live]
log facebook campaign heal --campaign <id>

As an MCP server

bin/logMcp.ts (bun run mcp) serves every command as an MCP tool over stdio, registered as log in the repo's .mcp.json, so in Claude Code they show up as mcp__log__<tool>. A command that declares its input (Command.input, one flag spec per form) is one tool per form, named for the command and its words (inspectStart, inspectDo, accountShow, runsPrune…), and takes named fields: the spec gives each its kind, what is required and the enum values, and the tool turns them back into the argv the terminal would run (argvOfInput), each value whole. The command parses with parserSpecOf of the same spec, so the usage, the parser and the schema can't drift (check:docs). A command without one is still one tool taking args (what goes after log <command>). Every tool returns the command's stdout, stderr and exit code, through runCli, so every gate above still applies.

  • What acts on Facebook is a tool of its own, named <tool>Live (publishLive, inspectDoLive, hostStartLive, joinLive, warmupLive, flow<Name>Live): a form's single live flag is implied (publishLive runs --live), several stay fields (hostStartLive: live, likes), and an args tool takes them in args. Every live tool needs confirmLive: true; every other tool refuses live and names its live twin. bypass needs confirmBypass: true. Set them only when matt asked (rules 8, 10, 20).
  • Every result, refusals too, says where every open browser is: browsers, next (the tool call that resolves a known page state: make it, rule 54), needsMatt and progress (each streamed line with its time), and its text starts with the same in a line or two. A client with a shell (Claude Code, Codex, by its clientInfo name) is told to Monitor the live log for long runs; any other client is told to read progress and browsers. The client's name is written to the live log when it connects.
  • A long tool (LONG_RUNNING, and every flow) runs as a job (src/mcp/jobs, ~/.logCli/mcp/jobs/<id>.json and .jsonl): the call waits waitMs (default 20 s, at most 50 s) and returns the result if the run ended, or the job (exit -1, job.id, the lines so far). jobStatus (log job status <id> --afterLine <n>) waits for new lines and returns the result once it ends; jobCancel stops it before its next browser step (sendInspect), never once Publish is clicked (rule 10); jobList lists them. One job holds an account at a time (accountBusy). Jobs live on disk, so any server or the terminal can follow them; a job whose server stopped is lost.
  • Over HTTP (--http), Claude's connectors (claude.ai, Duet) sign in with OAuth through Twenty when LOG_MCP_PUBLIC_URL is set: the server is its own authorization server (src/mcp/oauth: discovery, /oauth/register, /oauth/authorize, Twenty's callback, /oauth/token), and only the Twenty user of this server's log login gets a token. log mcp clients|revoke (terminal only) manage them. How it is deployed: docs/deployment.md.
  • Every tool carries a title and MCP annotations from its command's effect (read, write, delete, browser, on the command and its forms): reads are read-only and idempotent, a browser on Facebook is open-world, a prune is destructive, and every live tool is destructive and open-world, so a client can ask before each live call (src/mcp/server/helpers/toolAnnotations.ts).
  • connect always runs --noPrompt as a tool. It is the connect machine (marketplace/connect/machine.ts): every page is read as a kind (loggedIn, loginForm, twoStepCode, twoStepMethod, trustDevice, automatedBehaviour, cookieBanner, findAccount, checkpoint, captcha, wrongPassword, disabled...) and printed as a state, until signed in or --wait s. Each page takes Listaro's path (rule 23). launch and login aren't tools, because they wait on a person at the terminal and stdin is the MCP channel.
  • Taking an account from NEEDSLOGIN to a dry run: skills/accountToDryRun.
  • It loads log-cli/.env.local itself, whatever folder it is started from.
  • Every action records a HAR (rule 22). Each connect, scan, status, watch check and publish writes session.har, its timed steps.jsonl, and one rea-read HAR per step under ~/.logCli/runs/... (skills/reaCapture).
  • Every log line is visible. The worker's and publish session's stderr go through observability/processLog. As a tool, each line (with the command's own output) is in the result under log:, is sent as an MCP log notification, and is appended live to ~/.logCli/mcp/<day>.log (LOG_MCP_DIR to move it). The terminal CLI (bin/log.ts) appends its own lines there too, as cli:<command> (rule 55). Watch a long call (connect, publish) with tail -f ~/.logCli/mcp/<day>.log. Lines name actions and page paths only, never credentials or query strings.

Reading the results

Every code is in the error catalog: log errors lists them, log errors --code <code> explains one (meaning, retryable, where each machine ends, what to do), and docs/errorCatalog.md has them all with the machine diagrams. After changing src/errors/catalog/types.ts, run bun run errors:export.

You seeMeansDo
neverConnectedNo saved session for that accountlog facebook connect --account <accountId>
loginWall / sessionExpiredFacebook wants a fresh loginlog facebook connect again
checkpointed, suspendedAccount problem; the monitor stops at blockedTell matt, don't retry
wrongPasswordconnect: Facebook said Twenty's password is wrongFix it in Twenty, then log facebook connect
signInNotFinishedconnect: --wait ran out; the output names the last pageFinish that page in the window, or a longer --wait
trippedSame error 3 times in a rowRead the trace, fix the cause, run again
Repost yes (#n)A takedown that's within the repost budgetDecision only; nothing was posted

The rules these tools fall under, in full

From AGENTS.md (log://agents). The newest rules are at the bottom of AGENTS.md and win.

Rule 1. 2026-10-18 · camelCase everywhere, never kebab-case: files, folders, values, ids, statuses, error codes, usage text. Other systems' kebab-case is converted once, at the boundary. bun run check:naming enforces it.

Rule 8. 2026-10-18 · Read-only on Facebook. Nothing is posted, edited or deleted unless matt asks for it.

Rule 10. 2026-10-18 · matt asked for posting: log publish posts one listing through the publish machine. It is a dry run unless --live, and once Publish is clicked nothing is retried. Captchas go CaptchaKraken first, then 2Captcha, and Facebook's own challenges go to a person. Rule 8 still holds for editing and deleting.

Rule 11. 2026-10-18 · Camoufox is never headless. Every launch is headed so the window can be watched, with humanize on and the cursor shown (python/browser/visibleBrowser.py). There is no flag to turn it off. src/camofox/session/visible.test.ts enforces it.

Rule 12. 2026-10-18 · No browser opens for an account unless its proxy is attached and its exit IP is in the account's country (python/browser/proxyGate.py). There is no exception: not dry runs, not signing in (matt: "never, ever open" without one). Listings must be in the account's country, and the form's location must be picked in that country. How it works: docs/proxies.md.

Rule 13. 2026-10-18 · Twenty is the only place accounts and listings live: Facebook accounts are socialAccounts (platform FACEBOOK), listings are agencyListings. Never delete either: a banned or dead account keeps its record with its status, and its campaign listings get replacement accounts in the same country (docs/accountsAndListings.md). Fields are only ever added (scripts/twenty/twentySetup.mjs), never renamed or retyped, so the twitter-controller keeps working.

Rule 14. 2026-10-18 · Twenty is reached through the generated GraphQL client, the same one blaster and the dialer use (bun run twenty:client, src/twenty/graphql/generated). Regenerate it after any field change.

Rule 17. 2026-10-18 · Launch self-check: before any page loads, the gate checks in the page and a worker that the timezone is the exit IP's, the language and Intl locale are the pinned locale, and the browser's exit IP is the checked one. Any mismatch closes the browser before Facebook loads.

Rule 18. 2026-10-18 · No run outside the account's local waking hours (08:00-22:00 in its home timezone, or activeHoursStart/activeHoursEnd): publish, scan, status, watch, campaign run and connect are refused with outsideActiveHours. Daily caps count the account's local day.

Rule 20. 2026-10-19 · matt asked for --bypass: on publish, scan, status, watch, campaign run and connect it skips rule 18's waking hours for that run, and nothing else. The proxy gate, home locale, device and launch self-check (rules 11, 12, 15-17) still run, and --live still needs matt (rule 10). It is never on by default; pass it only when matt asks for a run outside the account's hours.

Rule 22. 2026-10-19 · Every action records a HAR, with no way to turn it off. The proxy gate puts a raw HAR recorder (rule 19) on every browser context, and refuses a context without a capture folder (captureMissing). That covers connect, scan, status, each watch check and publish. Each run keeps ~/.logCli/runs/<action>/<time>_<account>/firefox (publish: its run folder): session.har, steps.jsonl (every action the run logged, timed), and steps/NN_<action>.har, one per step, each read by the local rea (settleRunCapture). Never committed.

Rule 24. 2026-10-19 · matt: "fix them issues now" (the launch budget from RESEARCH/LOG_CLI_RUN_CAPTURES_2026_10_19.md). One account's browser opens on Facebook at most 8 times per local day, dry runs counted, at least 10 minutes apart. A launch counts once it passed the gate ("Self-check passed" in its steps.jsonl). Refused before anything opens: launchTooSoon, launchBudgetSpent (src/browser/run/helpers/launchBudget.ts). --bypass doesn't skip it; only a publish against the saved fixture form (--formUrl file://...) isn't counted. A publish meets a login wall or checkpoint in its own browser (the connect steps run there), never in a second launch.

Rule 25. 2026-10-19 · matt: a run that fails or hangs is fixed from its own recording, never by guessing. Read its HAR with rea (timeline, cause line, compare runs), then fix the machine. The outline is at the top of this file.

Rule 26. 2026-10-19 · matt: "I always want to see what is going on." Every process that runs over time writes a progress line per stage as it happens (never only at the end) and to a log file in its folder. Agents run long commands so the lines stream live (Claude Code: Monitor, never piped through tail/head).

Rule 27. 2026-10-19 · matt: every request Facebook's client makes in our runs is documented by domain in docs/platforms/facebook/<domain>/<request>.md: what it sends and reads, which step fires it, how fast, what errors it returns, and why it matters to our machines. Built by log capture --docs: our syntax-tree parse cuts each request's modules out of Facebook's bundles, rea reads each slice (seconds, not the whole bundle). lefthook's pre-push (check:facebookClientDocs) refuses a push while a request is undocumented, unread by rea, has its reasoning unwritten, or a run is newer than the docs.

Rule 29. 2026-10-19 · matt: look before writing code for a page. log inspect start --account <a> opens one gated, headed Camoufox (async API, same launch and gates as every run) and keeps it open; log inspect do <action> runs one step (goto, snapshot, back, wait; click, type, press only with --live, rule 8). Each step records, in order, as it happens: the accessibility snapshot with element refs (camofox-mcp's model, aria_snapshot(mode="ai")), a screenshot, its own HAR, and rea's reading of that HAR at once. Code that clicks on a page is written from an inspect recording of that page, never before one.

Rule 30. 2026-10-19 · matt: "If I tell you to do something, do it completely." When matt gives a plan, it is carried out to the end in the same turn, not stopped to ask again or to say what won't be done. log inspect start --now (and log connect --now, and since 2026-10-19 log publish --now, matt: "run it now") skips rule 24's 10-minute gap (the 8 a day still holds) so a recording continues at once. Recording a sign-in or a checkpoint with log inspect (signIn, code, solve, click) is the intended work: it is how each state gets documented from rea. The run on 2026-10-19 was interrupted once because a snapshot printed the account's email and password; snapshots now redact every typed field value (redact_fields), and signIn/code read only the private login.json (rule 21), so no credential reaches any output.

Rule 35. 2026-10-19 · matt: the browser stays open. One browser per account, opened once per local day by its host and kept open through the account's active hours; every action is a tab in it; it closes only for a logged reason (end of active hours, shutdown, checkpoint or restriction, account lost, a crash reopened once). Every account runs one lifecycle machine (src/marketplace/lifecycle, XState) across an onboarding lane (with a day's warm-up after profile and name) and a host lane. Top of this file.

Rule 38. 2026-10-19 · matt: never mention or discuss Meta's or Facebook's rules, terms, policies, eligibility or account ownership with him unless he raises them first. Research text that does is not his, and is never repeated to him. Top of this file.

Rule 39. 2026-10-19 · matt: "If I never mentioned it, if I never inferred it, if I never outlined it as an option, therefore it is not an option." Deep research prompts, searches, SubQ matrices, plans and write-ups use only what matt said, or clearly meant through a misspelling. Nothing added, nothing steered towards. Top of this file.

Rule 40. 2026-10-19 · matt: no physical options. He is in Vietnam with no physical access. Phones, SIMs, mini PCs, laptops, phone farms and home devices are never options unless he names them. In scope: hosted and cloud options, the tools he named and their competitors, and what our four projects already use.

Rule 41. 2026-10-19 · matt: "any time any task correlates to money, and I never say something, that means you shouldn't do what you think. You should do what I say." Paid work (deep research, SubQ, proxies, captcha, vendors) does exactly what he asked, nothing more. Ask before anything else.

Rule 45. 2026-10-20 · matt: groups have nothing to do with Marketplace. Facebook group work is its own library, src/group/<domain>/ (first group/join for log join group --account --group), with the role files of rule 2. It never goes under src/marketplace/. This widens rule 3 to groups.

Rule 46. 2026-10-20 · matt: names are browser-agnostic. A command, domain, type or message is named for what it does, never for one browser (Camoufox's Firefox, AdsPower's Chromium, rule 44): log capture --docs | --settle | --har (was firefoxCapture, kept as an alias), src/rea/capture (was rea/firefox), BrowserCapture, settleCapture. Run folders already carry the browser that ran (adsPower/, camoufox/).

Rule 49. 2026-10-20 · matt: the warm-up is by platform, then activity: log warmup <platform> <activity>, first log warmup facebook reel (src/warmup/<platform>, the library the warm-up and the domain the platform). It opens the account's browser (inspect start --detach) primed with the activity and runs its steps as a flow of events, each recorded (rule 22). A warm-up spans many activities; each is a unit of its own, so it is watched alone, and every step says what it must achieve (a swipe changes the reel's URL, the comments show their heading): each run writes the outline of the steps that didn't (warmup_<platform>_<activity>.json), and the steps are fixed from that recording (rule 25). Reels: sound on first (they start muted: nobody watches a reel with the audio off); most reels get a glance and a skip, often a few in a row, and only some are watched (--skip, default 0.6); on those: Next card, the comments opened, scrolled (the wheel over the panel, scrollIn) and closed, the creator's profile, a like only with --likes (rule 8); a session is drawn from ranges (plan). Its requests are in docs/platforms/facebook/reels/ (rule 48).

Rule 50. 2026-10-20 · matt: the Reels warm-up builds the persona's For you feed by engaging with what it would engage with, from the datasets (the persona-conditioned Reels handoff he collated; ~/.logCli/datasets/SOURCES.md). log warmup facebook reel run --persona keeps one persona per account (the three datasets he named), one real viewer from KuaiRand-1K (src/warmup/viewer, matched to the persona's scroll speed) and one persona pack (buildPersonaPack): every dataset gives a trait, said in its provenance, real or synthetic, including what the persona is into (interests). Each reel, the moment it lands, is read by SubQ from its frame (matt: SubQ analyses the images), with the caption and the persona: what it is, whether it is this persona's kind of content, the one action they'd pick. The action orderer (planReel, deterministic from the seed) then plans it: the watch is the viewer's next real play scaled by the interest effect measured on KuaiRand-Pure's random log (their kind ×1.10-1.67, outside ×0.90-0.99; likes ×1.28 against ×0.95, comments ×1.40 against ×0.93, scripts/datasets/buildInterestEffect.ts), and at most one action (U = p × interest effect × segment weight × attention fit, a like cooldown of 3 reels, only what the run allows, rule 8), a close call settled by the read's pick. The watch counts from when the reel landed, so SubQ's answer is the glance. rea still reads each step at once without holding up the next (reaLater, rule 29); a dwell doesn't wait for the network, a swipe only for the next reel's URL, and the cursor rests on Next card between quick swipes. Save, follow and share aren't built (#46).

Rule 51. 2026-10-20 · matt: proxy traffic is money. Reels ran 2Captcha's traffic out (about 4.5 MB a reel through the residential proxy), so each account has a budget of its own traffic for 24 hours, 1 GB by default (--trafficMb). A warm-up reads 2Captcha's counter before and after, refuses to start over the budget (trafficBudgetSpent), checks every 5 reels and stops at it, and writes each run's use to ~/.logCli/proxyTraffic.jsonl (src/proxyVendor/traffic).

Rule 52. 2026-10-20 · matt: proxies are provider-agnostic, and Byteful is added beside 2Captcha (this widens rule 43). Twenty keeps one proxy URL per account, whichever provider it came from. The provider is read from its host (vendorOfProxy, src/proxyVendor/vendor), and each provider is a domain of its own (src/proxyVendor/twoCaptcha, src/proxyVendor/byteful). log proxy assign --vendor byteful takes a sticky (24 h) Byteful residential proxy in the account's city and checks it (home country, 100 km, speed) before saving, as for 2Captcha. log proxy balance shows every provider set up, and log proxy static lists Byteful's static IPs with where each is. A traffic-billed proxy is metered by its own provider (rule 51); a static IP is not. log-cli never buys from a provider (rule 41). Docs: docs/proxyVendors/; Byteful's own docs and API spec are in RESEARCH/MARKETPLACE_PROVIDERS/proxies/byteful/.

Rule 53. 2026-10-20 · matt: when something has to be found (a group to join, a provider, a price, a fact), it comes from an external source, never from memory or a guess: search with TinyFish through treg (treg call tinyfish.web.search, tinyfish.web.fetch, free) as the basis for every lookup, and pull the actual candidates from it. A suggestion names where it came from (the page, the search), and a pick is one of what the search returned.

Rule 54. 2026-10-20 · (checkpoints: replaced by rule 118, matt 2026-11-02) matt: a known state is resolved, never a reason to stop. When a page lands on a state log-cli already knows (a checkpoint's first page, a text captcha, the Remember password prompt, the login form, two-step, "automated behaviour"), its resolver runs: press the checkpoint's Continue (plain text, by its ref), solve the captcha (2Captcha, then Continue), Not now, signIn, code. Only what a person must clear stops an account (a video selfie, an ID or photo, a disabled account; rules 10, 23), and that is said to matt. A Claude Code hook (.claude/settings.json, scripts/hooks/pageState.ts) reads the latest step of every open browser after each tool call and each prompt, matches it against the known states (pageStateOf, src/browser/flow/helpers/pageState.ts) and puts the state and its resolver into the agent's context. A state not mapped yet is pressed through (its Continue) and added to the map.

Rule 55. 2026-10-20 · matt: the CLI, the MCP tools, the agent hooks and the live log line up, and a commit is refused when they don't. One hook list (src/agentHooks/registry) writes both hosts' files, Claude Code's .claude/settings.json and Codex's .codex/hooks.json: SessionStart and UserPromptSubmit say where every open browser is; PostToolUse (Bash, PowerShell, Monitor, every mcp__log__ tool) gives a known state its resolver, and a failed step is an unconfirmed page (snapshot, then resolve); Stop keeps the turn going while a browser sits on a state log-cli resolves itself (rule 54), never on a person's state, and never twice on the same unchanged one; PreToolUse refuses a log run piped through tail/head (rule 26). The terminal CLI writes the same live log as the MCP, ~/.logCli/mcp/<day>.log, so one Monitor shows a run however it started. lefthook's agentHooks (bun run check:agentHooks, pre-commit and pre-push) refuses drift: both hosts' files, every command an MCP tool or terminal-only, every MCP tool hooked, every hook script answering its events, every resolver a real command (skills/agentHooks/SKILL.md).

Rule 56. 2026-10-20 · matt: the README says what every command, MCP tool and hook does, with Mermaid diagrams kept as .mmd files, all generated from the code. Each command carries its full usage (Command.usage), so its flags are in one place: the terminal's usage, its MCP tool's description and the README's table (command, what it does, flags, MCP tool, the hooks that fire on it from a shell and over MCP, live log, rules) all come from it. src/surface builds the catalog (catalog), the diagrams (docs/diagrams/surface|commands|hooks|pageStates.mmd) and the README's section between the surface markers; bun run docs:build writes them. lefthook's docs (bun run check:docs, pre-commit and pre-push) refuses a README or .mmd that differs from the code, and a command that parses a flag its usage doesn't name, or names one it doesn't parse.

Rule 57. 2026-10-20 · matt: what every browser uses lives under src/browser, named for what it is, never under a browser's name (rule 46). flow, inspect, run and proxy moved from src/camofox/ to src/browser/, beside driver, adsPower and camoufox: they drive AdsPower and Camoufox alike (rule 44). src/camofox/ keeps only what is Camoufox's own: device, session, worker. The browserParity check allows these shared domains (SHARED_DOMAINS) and nothing else that isn't a browser.

Rule 58. 2026-10-20 · matt: no loose files. python/ and scripts/ hold domain folders only, as src/ holds {library}/{domain}/. Python: browser/ (browserDriver, adsPowerBrowser, visibleBrowser, proxyGate, harRecorder), human/ (humanMouse, scrollMotion, recordMouse), session/ (inspect, connect, publish, profile sessions, logWorker), login/ (autoLogin), device/ (deviceProfile), errors/ (errorCodes, errorCatalog.json), fixtures/; each is a package (__init__.py), imports are by package (from browser import proxyGate), and a script run directly puts python/ on its path. Scripts: checks/, commitLog/, datasets/, docs/, hooks/, twenty/. lefthook's layout (bun run check:layout, pre-commit and pre-push) refuses a file loose at the top of either, tracked or new, and a Python domain without its __init__.py.

Rule 59. 2026-10-20 · matt: the README describes log-cli as it is: browser-agnostic, working through AdsPower first, with more browser providers to come, each one more driver. Which browser each command runs on is code (commandBrowser in src/browser/run: the account's browser through the driver, Camoufox only while not on the driver yet, or none), and the README's Browsers section, its Browser column and docs/diagrams/browsers.mmd are generated from it. check:docs parses every diagram with Mermaid, as GitHub does, and refuses one that doesn't parse (a ; in a note broke the hooks diagram on GitHub), a command summary that names one browser (unless it is that browser's own command or Camoufox-only), and a README heading outside the generated section that names one browser.

Rule 60. 2026-10-20 · matt: log-cli is platform-agnostic as well as browser-agnostic. Facebook (Marketplace and groups) is the first platform, and what is scoped by platform names it in every form: log warmup <platform> <activity>, with no form that skips the platform. Listaro's five Facebook activities are the Facebook activity stages: log warmup facebook stages plan|record|run|log (was log warmup plan|record|run|feed; those are refused with the form they became), their code in src/warmup/facebookStages (was src/marketplace/warmup), and their log the stage log (~/.logCli/runs/warmup/stageLog.jsonl, was the "activity feed", which clashed with the Facebook feed activity). Every command declares its platform (Command.platform: facebook, any or byArgument, a typecheck error without one), the README shows it in its Platform column, and check:docs refuses a byArgument command with a usage line that doesn't name the platform. The Facebook-only commands that don't yet take the platform in their name are listed in issue #20 (github.com/matthewdonsemail-lab/log-cli/issues/20), to be scoped as matt decides.

Rule 63. 2026-10-22 · matt: the platform comes first. Every command that acts on one platform is log <platform> <command> … (log facebook warmup reel, log facebook join group); there is no form without it. The poll is log facebook poll <group> <account> <how long to poll>. Old forms are refused with the form they became. This replaces rule 60's log warmup <platform> <activity> and settles issue #20. OpenMagpie's worker is log facebook poll --request (it was read group --worker), a read in the account's open browser. bun run check:docs refuses a usage line without the platform first. Top of this file; the table is docs/commandNaming.md.

Rule 64. 2026-10-22 · matt: the code is by platform first, as the commands are, "because each has different approaches": src/<platform>/<area>/<domain>. Facebook's groups are src/facebook/group/{join,read,answer} (were src/group/…, rule 45) and its warm-up src/facebook/warmup/activities and src/facebook/warmup/stages (were src/warmup/facebook and src/warmup/facebookStages, rule 49). What every platform's warm-up draws from stays shared: src/warmup/persona, src/warmup/viewer. Each area has an index.ts (check:naming). Top of this file.

Rule 65. 2026-10-22 · matt: Facebook group listening is the triage job of obris-dev/openmagpie#188, and it replaces rules 61 and 62. Everything is indexed in OpenMagpie, and only the matches reach Twenty. An account joins (log facebook join group). OpenMagpie polls, and its worker is log facebook poll --request, a read in the account's open browser. OpenMagpie keeps every post, its watch triages them (semantic_filter), and its webhook sends the matches to Twenty's socialListening. log-cli then acts on the NEW rows from the same account (not built yet, #40). log-cli writes only the group's readCursor, lastReadAt and lastReadReason to Twenty. agencyGroupPosts and agencyGroupComments stay, unused. The host reads no group on its own. Comment reading is kept for acting on a match, for new posts only, and saves nothing. #28's scoring and alerts become OpenMagpie watches. Spec: docs/groupListening.md.

Rule 70. 2026-10-22 · matt: no code comments, because they cause context rot in the system designs. Code carries no comments and no docstrings: TS, JS and Python, line, block, JSDoc, # and docstrings alike. What a thing does is said by its name, its types, its tests, its skill and its docs, never by a comment beside it. lefthook's noComments (bun run check:noComments, pre-commit and pre-push, scripts/checks/checkNoComments.ts, Python through python/checks/comments.py) refuses any tracked code file that has one; --strip removes them. Generated code is exempt. lefthook.yml itself carries none.

Rule 71. 2026-10-23 · matt: an ad says the same thing several ways. An ad that goes out on more than one listing in a city carries copy versions (agencyListings.copyVersions, title-and-description pairs written by skills/listingCopy), at least as many as the listings it will have live in that city and never fewer than 3, every two titles differing by at least half their words (the city's not counted). Each copy (campaign fill, heal) takes the version least like what is live in its city and records it in copyVersionId; a replacement skips the lost copy's version. log facebook listing lint, listing create and campaign fill refuse a version that breaks a checkable copy rule, and campaign fill refuses too few (notEnoughCopyVersions). src/facebook/marketplace/copy.

Rule 72. 2026-10-23 · matt: a Marketplace location is never abbreviated. It is typed as "City, State" with the state written out ("Bethlehem, Pennsylvania") or "City, Country" ("Galway, Ireland"): "Bethlehem, PA" was offered only as the West Bank, and a bare zip offered Granada, Spain first (rule 68). A check blocks before any action: a place with an acronym, a state code, a zip or any number, or no state or country is refused with locationAbbreviated (placeProblem, src/facebook/marketplace/location; python/location/placeName.py in the sessions), in a listing's locationQuery (listing create, lint, publish, campaign fill), log inspect do setLocation, a flow's location, and the host's setLocation, which types the listing's locationQuery and never a zip. A written-out state counts as the US for the country check.

Rule 73. 2026-10-23 · matt: "IT MUST ALWAYS READ THEM." The number one rule of the stack, above rule 30 and every other: Facebook's internal API answers are the decision-making factor of what and when something is relevant, and the deterministic factor of the system. Every browser step reads the internal API answers it triggers and the requests it sent, keeps them past the prune, and decides on them, never on a page loading, a timer or the step having run. The MCP instructions put it first. Inspect steps read theirs since rule 74, and every flow, warm-up and host step on AdsPower that makes a call since 2026-10-23; the Camoufox-only machines don't yet (#35). Top of this file.

Rule 74. 2026-10-23 · matt: in the inspect flow, a state we can't decide is read by SubQ with everything around it, and said loudly to the agent. Only log inspect does this, because it is where flows are made to be run again and again. Every inspect step's internal API calls are taken out of its recording by code, with what was sent and what came back, in seconds from the step's first request (exchanges.json, exchanges.md). A step no known state names (pageStateOf) is collated into one bundle: the goal (--goal), the account (home, platform, browser, the proxy's exit), what we did (the action, the mouse, how long it took), every internal API call, the page text, the screenshot and the earlier steps. SubQ's Decisions API (subq-snap-1.1, up to 128 questions, a 384K-token context) is asked what it means, not what to do next: whether the goal was met, whether everything fits the account, whether something should never have come up, whether the page agrees with the API, whether our action caused it, and what each call says about the goal. The reading is printed first in the step's result and put into the agent's context by the page-state hook (>>> UNKNOWN STATE), so it is named before the flow goes on. The same works on any platform. log inspect read <stepFolder> reads a recorded step again. src/subq/stepReading.

Rule 75. 2026-10-23 · matt: the loud events trigger a Claude hook, and the model keeps going until the goal is done, with what went wrong written down as P.E.E. An inspect session has a goal (--goal on start, or log inspect goal --set). The goal is reached when a step's SubQ reading says so (goalMet at least 0.5). Until then the page-state hook's Stop event holds the turn ("Keep going with the next inspect step"), and puts the goal into the agent's context after each tool call when it changes. A step whose reading says it went wrong (wrong result, stopped by the platform, unclear, or SubQ failed) needs its finding before the next step: Point, the issue in one sentence; Evidence, quoted from the step's reading (the calls SubQ flagged, with what was sent and what came back), plus any of your own; Explain, why the evidence shows it and what caused it (log inspect finding --step --point --explain). Once the goal is reached, the turn is held until every finding says how it was fixed (log inspect finding --fixes <n> --fix). They are kept in the run as goalFindings.jsonl and goalFindings.md; log inspect goal shows them. What only a person can clear never holds the turn: it is said to matt. The hook holds the turn once per unchanged state, never twice on the same one. src/browser/inspect/helpers/goal.ts.

Rule 76. 2026-10-23 · matt: "make it the number one rule of AGENTS.md and README.md: if we don't know, we do this." When a step lands somewhere we don't know: read the internal API (rule 73), collate everything about the moment, ask SubQ what it means (rule 74), say it loudly, keep going until the goal is reached, write what went wrong as P.E.E. with its fix (rule 75), and name the state. It is the first section of AGENTS.md, the first section of README.md and the first MCP instruction. Top of this file.

Rule 77. 2026-10-24 · matt: a group's members are read from its People tab. log facebook read members --account <a> --group <url|id> --count <n> [--secondsPerScroll <s>] [--maxScrolls <n>] [--owners] [--bypass] [--json] (beside read group; MCP readMembers, no live twin) goes to /groups/<g>/members in the account's open browser (rule 35) and scrolls, nothing clicked (rule 8). The members come from each step's own recording (rule 73): the page's HTML gives the first 10, and every scroll's GroupsCometMembersPageNewMembersSectionRefetchQuery answers about 10 more each, two a scroll (inspect 20261024T060921): name, profile, bio, page category, followers and when they joined. matt: "the time it takes for 40 scrolls is interesting and should also be a variable": --secondsPerScroll (default 15) is the wait before each scroll, and it says up front "count N → ~S scrolls, ~M min at Xs per scroll". It stops at --count, when the list ends (has_next_page false) or at --maxScrolls. --owners keeps a bio naming owner, co-owner, founder, CEO, president, proprietor or self-employed, or a page (a category set). The members go to ~/.logCli/members/<group>/<run time>.tsv and .json, never to Twenty. A members page whose refetch answered with members is the known state groupMembersList, which needs nothing: no resolver, no SubQ reading, and the Stop hook doesn't hold the turn on it. src/facebook/group/members, docs/groupMembers.md.

Rule 79. 2026-10-25 · matt: "we can't do that in production, that's why we need it deterministic." A flow step never depends on a page's exact wording or a person stepping in. A control is picked by one scored match (python/browser/controlMatch.py): its role, then its name as the accessible name, aria-label, label, text or placeholder, case and spacing ignored, inside the dialog the step names (within, taken from the recording's snapshot) or an open dialog over the page behind it; two equal matches are refused (controlAmbiguous), none is controlMissing, a disabled one is waited for (controlDisabled). Every step waits for the call it must cause (expect: a mutation, a plain endpoint such as profilePictureUpload, or a dialog query) and checks its answer (rule 73) before the next one fires. log facebook preflight says before any browser opens what would stop each account (home timezone, hours, proxy country and speed, the browser kernel, launches and host opens left, a session left open, its picture on disk); --apply downloads a missing kernel and the host waits for one. A session whose process is gone is dropped. Snapshot excerpts of each page version are test fixtures (python/fixtures/controls/). Before a flow's first click it clears the known overlays with Escape, each checked: the browser-notifications notice (gone from the page) and an ended "What happened" notice (useAccountStatusClearEnrollmentEntryPointActionMutation answers xfb_ixt_gateway_clear_flow: true); one that stays stops the flow with overlayNotCleared.

Rule 80. 2026-10-25 · matt: X (Twitter) is log-cli's second platform, built around a vendored client the way Facebook's direct API is built around fbk: twikit (vendor/twikit, unclecode's fork) and twitter-controller (vendor/twitterController, the code being ported). Its commands are log twitter <command> (src/twitter/<area>/<domain>, python/twitterApi/engine.py), declared directApiOnly in DIRECT_ROUTES. matt: "No: X accounts call X directly as the controller does today": no proxy is required or used for an X account, and rule 12 stays Facebook's. X's own answer decides (socialAccounts.sessionState: loggedIn, loggedOut, restricted), each account has its own governor under ~/.logCli/twitter/<account>, and X's refreshed cookies are merged back into the row. Discovery stays treg's (rule 53). First command: log twitter session check. Plan: docs/twitterPlatform.md.

Rule 81. 2026-10-25 · matt: we own vendor/twikit and keep it up to date ourselves, since nobody upstream maintains it. Its HTTP transport is curl_cffi, the way vendor/fbk does it, never httpx: self.http is a curl_cffi AsyncSession pinned to one impersonation target (IMPERSONATE, safari17_0, matching twikit's Safari user agent), redirects not followed (as httpx did), the proxy kept in the session's proxies, media chunks sent as a CurlMime. Only the transport changed. python/checks/twikitTransport.py finds every httpx touchpoint by syntax tree (--map), rewrites the ones with a curl_cffi equivalent (--apply) and refuses any left (--check, bun run check:twikitTransport, pre-commit and pre-push); vendor/twikit/tests/test_transport.py holds the behaviour.

Rule 83. 2026-10-25 · matt: a new platform is made with one command, written normally: log new platform <name> (MCP newPlatform). It writes the platform's account session on the shared src/platformKit/session, log <name> session check with its MCP tool, a direct-API engine in python/<name>Api on the shared python/platformKit/engine.py, and docs/platforms/<name>/README.md, and wires it into the repo by editing each file through its syntax tree (PLATFORMS, PLATFORM_LABELS, COMMANDS, createContext, CommandContext, DIRECT_ROUTES, COMMAND_RULES), checking every edit landed before anything is written. Without --apply it only lists what it would do; nothing is overwritten; it names what Twenty is missing (the socialAccounts.platform option) and never changes Twenty. Routes and rules are keyed by the platform-qualified name (commandKey: read, twitterSession, redditSession), so two platforms' commands never share one. lefthook's platforms (bun run check:platforms, pre-commit and pre-push) refuses a platform missing its label, src, command, engine, session route, rules or docs. src/scaffold/platform, src/cli/new.

Rule 84. 2026-10-25 · matt: log inspect and the account's AdsPower browser work on every platform, not only Facebook, so a new platform is explored the same way (rules 29, 48). One table says what each platform is, PLATFORM_SITES (src/cli/runner/helpers/platforms.ts): its Twenty value, cookie domain, hosts, home page, whether it needs a proxy and whether it has a browser path. Facebook needs its proxy (rule 12, unchanged; the gate refuses a direct run for it); X has no browser path (rule 80). matt, for Reddit: "no proxy, like X": a platform marked proxy: none opens its AdsPower profile with no proxy, the gate reads this machine's own exit and the profile's timezone follows it; an account with no home runs in en-US on the machine's timezone. log inspect goes only to known platforms' hosts, opens on the account's platform home, carries the account's own cookies in, and names each internal call by Facebook's friendly name or a GraphQL operationName/operation, else its path. log adsPower sync gives a profile to every browser platform's accounts (a no-proxy one needs no home). A row whose accountName is empty is found by its name. log new platform writes the platform's row (--hosts, --home, --noProxy, --noBrowser), and check:platforms refuses a platform without one.

Rule 85. 2026-10-25 · matt: "THE WHOLE POINT IS TO GO THROUGH REA." Every platform's internal calls go through rea, their code as well as their network, never Facebook's alone. log facebook capture --docs builds every browser platform's request docs (docs/platforms/<platform>/): it takes each run's calls to that platform's hosts (PLATFORM_SITES), names each by its GraphQL operation or its path without ids, finds the code that sends it in the platform's own bundles by syntax tree (an operation by its exact name, a path where it is fetched), and rea reads each call's slice (src/rea/platformClient). A call whose code isn't in the bundles keeps its step's rea reading. The docs gate (check:facebookClientDocs) refuses a platform's doc that rea hasn't read or whose reasoning isn't written. The first Reddit run: 39 calls, 33 read from their code, the other 6 from their step.

Rule 86. 2026-10-26 · matt: "if I say to do something it can force past it." An X send can go past the account's governor (its pause after a block, its hourly, daily and write caps) when matt says so: --force on log twitter reply|quote|like|dm and log twitter queue run (MCP force). Only for that call or that run, only when matt asked; never by default. X's own answer still decides (rule 73) and the queue's own cool-offs still apply. The answer says what it went past (forcedPast). The governor still counts the request.

Rule 92. 2026-10-25 · matt: the self-check's languages are checked by order. AdsPower's browser (SunBrowser 154) adds en-US, en-GB after the profile's own languages, whatever the profile says (Kamloops, 2026-10-24: en-CA, en, en-US, en-GB, three starts refused). The self-check passes when navigator.languages starts with the pinned languages (en-CA, en), in page and worker, so the account's language still comes first; extras after them are allowed. language, Intl and the number locale are still exactly the pinned locale (rules 16-17).

Rule 93. 2026-10-27 · matt: "we need to build a knowledge base inside of it", all of it, agent memory too, with Redis. The knowledge base lives in ListeningKit's Twenty on node02 (knowledgeArticles: key, kind, body, source, tags, author, content hash; reached over the tailnet at node02.tail4fcf85.ts.net, never through the public login gate) and is searched through Redis 8 on node02 (kb-redis, the search module's kbIdx: BM25 keyword and HNSW meaning over local all-MiniLM-L6-v2, fused with reciprocal rank fusion). log kb sync collects AGENTS.md's rules and sections, every skill, every doc (docs/infra/ as infra, never docs/vendors), every Claude memory file, the known page states and the accounts from log-cli's Twenty (no credential, proxy URL or cookie), writes what changed to Twenty and re-indexes what changed into Redis; an article a person writes or edits in Twenty is indexed on the next sync. log kb search "<query>" [--kind] is how an agent finds context, log kb remember "<text>" saves an agent memory to both at once, log kb show <key> reads one in full. Secrets are scrubbed before anything is written (named keys, URLs with a login, JWTs, private keys, long random strings). The logins are files in ~/.config/listeningkit/, never in the repo or the knowledge base. src/knowledge/base, docs/knowledgeBase.md.

Rule 94. 2026-10-29 · matt: "whatever you did in such quick succession lead to my account getting disabled, never do that again." Never act on an account in quick succession. What happened (matt's Discord, sourcedandsoldtothehighestbidder, 2026-10-29 00:47-01:00 UTC): in about 13 minutes an agent replied in 3 chats, read 1,400 messages from a server (Finn Automated) with a scratch script that went around log-cli (.scratch/finn/dc.ts: its own fetch to discord.com/api/v9 with the session token, pages back to back, retrying straight through 429s), pulled the account's relationships, then opened new DMs to strangers from that server 30 s apart. Discord disabled the account. From now on: - Every call to a platform goes through log-cli's own commands and their governor. Never a hand-rolled script with an account's token or cookies. - A 429 or a failed send stops the run. It is never retried through, and the next action waits for matt. - Reading a server, group or member list in bulk is its own session. It never runs in the same session as sending. - Cold DMs to people the account has never talked to go out at most a few a day, minutes apart, never back to back. Each new DM channel opened counts as a write. - After a block, disable, captcha or cooldown, nothing resumes on that account until matt says so.

Rule 95. 2026-10-30 · matt: the tool definitions fill the agent's context with how matt works, so every agent knows how to do every job. The work matt asks for again and again is written as use cases, docs/useCases/<platform>/<page>.md: each one says how he asks for it, then its steps in order, each naming its tool and what counts as done. The log MCP serves them like Paper's MCP serves its guides: short instructions that say to load getGuide({ topic: "logMcpInstructions" }) first (it lists every use case and how matt asks for it), getGuide({ useCase: "<platform>/<page>" }) for one page, and every tool's description naming the use cases that call it and its guide. When matt asks for work a use case covers, the agent follows that page, never improvises. Every tool declares anthropic/maxResultSizeChars so a guide arrives whole. lefthook's pre-commit useCases (bun run check:useCases, src/useCases/review) takes this session's transcript (matt's asks and the tools called) and asks SubQ, for every page, whether it is still the right way to do that work, stale or should move, and whether the session did repeatable work no page covers. A stale page or uncovered work stops the commit until the page is updated or written and staged.

Rule 96. 2026-10-31 · matt: "the new twitter budget 90". An X account makes up to 90 writes a day (dms, replies, quotes, likes, reposts, follows, posts), not 40 (WRITES_PER_DAY, src/directApi/engine, handed to the X engine's governor in each run's spec). Discord keeps 60, every other platform 40. X's own answer still decides: a 344 (its daily limit) stops the run, and nothing more goes out until matt says so. log twitter repost (MCP twitterRepost, twitterRepostLive) reposts one post, a write like the others.

Rule 97. 2026-10-31 · matt: "the ability to have everything done locally". Accounts live in a local file, ~/.logCli/localAccounts.json (LOG_LOCAL_ACCOUNTS moves it), on every platform, and this narrows rule 13 for accounts only. log localAccount pull copies every Twenty account in, cookies and login included, each keeping its Twenty id so its listings and groups still find it; add makes one Twenty never had (with its AdsPower profile); list shows them. LOG_ACCOUNTS=local (.env.local) leaves Twenty out of accounts entirely, and a session a run captures or refreshes is saved back to the file. Listings, groups and conversations stay in Twenty.

Rule 98. 2026-10-31 · matt: "So the opener always relevant. Don't just go in for the jugular. Go in with the opener." A first message to someone matt has never talked with is the opener and nothing else: about them and something they posted (or reposted, or that they reposted him), always relevant, and never the ask: no pitch, no "you using treg yet", no call, no link to his own stuff. The ask waits until they have answered. log voice fails a first message with an ask in it (openerOnly, src/twitter/copy/voice), and someone with nothing relevant to open on isn't messaged yet. docs/useCases/twitter/engagerDms.md, networkMaxing.md, GUIDES/MATT_WRITING_STYLE.md.

Rule 99. 2026-10-31 · matt: "network maxing is basically making it so the conversation will be getting to know about them and what they're doing. But the whole point of it is to expand our network, which is the niche map." Network maxing grows the niche map (the people around the communities and creators in his niche, with their role, whether they follow him and how close they are). New people come from it first: real roles, closest first, not yet talked to. Every message is about them and what they're doing, never what matt sells (rule 98 for the opener). Where the map lives is in the agent memory, not in the repo. docs/useCases/twitter/networkMaxing.md.

Rule 100. 2026-10-31 · matt: "reactions are indicators of open messages, so we can then add that as a thing we can follow up on", then "for our already opened messages". When someone's last event is a reaction to matt's message, or their read receipt is past his last message (X Chat's read-marker events, the old DM API's last_read_event_id), they opened it and didn't type back: it isn't a reply waiting on him, it's a follow-up to send. log twitter dms --waiting lists those apart (reactedOnly, openedNoReply, followUpDue, src/twitter/dm/person; openedByThem on each thread; --json gives followUps), and the follow-up is the network-maxing kind: about them and what they're doing, never the opener again and never the ask. docs/useCases/twitter/networkMaxing.md ("Everyone who reacted"), dmInbox.md.

Rule 101. 2026-10-31 · matt: "increase it to be 240 writes to twitter". An X account makes up to 240 writes a day, not 90 (this replaces rule 96's number; WRITES_PER_DAY, src/directApi/engine). X's own answer still decides: a 344 stops the run and nothing more goes out until matt says so. Discord keeps 60, every other platform 40.

Rule 102. 2026-10-30 · mandeep: LinkedIn goes out looking like a browser, the way X does (rule 81). vendor/openLinkedinApi reaches LinkedIn only through one curl_cffi Session in open_linkedin_api/transport.py, pinned to one impersonation target (IMPERSONATE, chrome150, the closest to the Chrome its cookies come from), sending the target's own user agent, never the library's fixed one or plain requests. python/checks/linkedinTransport.py refuses anything else (bun run check:linkedinTransport, pre-commit and pre-push). Every LinkedIn call runs through the account's governor (python/platformKit/engine.py), and a LinkedIn account writes at most 10 times a day (WRITES_PER_DAY), not 40 (rule 91).

Rule 104. 2026-11-01 · matt: "the timing stuff there i saw you hand rolled the outbound stuff when it should be queued and it has to have random intervals, but the bundle itself actually reveals more information". On 2026-11-01 matthewsoldit got a soft block, then X's 226 ("looks automated") on a reply. The live log showed 8 thread reads fired in 0.2 s by two parallel helpers while sends went out from a hand-written loop, and every log twitter call was a fresh client that loaded x.com's home page and its signer before acting, so a 3-line dm was 3 cold starts in 9 s. X's own web client (sign.o in its x-web bundle, read 2026-11-01) loads the page once and signs every call from it. From now on: more than one send goes through the queue (log twitter queue build|plan|run, random times), never a hand-rolled loop and never helpers reading or sending on one account in parallel. The X engine takes one call per account at a time (engine.lock, refused with accountBusy after 15 min), waits 4 to 11 s after the last call, and keeps the transaction key in clientTransaction.json for 3 hours (python/twitterApi/engine.py, vendor/twikit ClientTransaction.state and restore).

Rule 105. 2026-11-01 · matt: "we cannot be opening multiple sessions there, that defeats the core purpose of what we use this tool for ... it should always be one, and then it needs to loudly create issues". An X account has one session, always. Every X call (CLI, MCP, hooks, the queue, the X Chat watch) goes through the account's one engine host (python/twitterApi/host.py, started on the first call, closed after 30 idle minutes or when the engine code changes): one twikit client, one connection, one cookie jar and one transaction key, the way X's own web app keeps one page and signs every call from it (x-web startup-dynamic, read with rea 2026-11-01). A second host can't start (host.lock); a call that can't reach the host is refused with xSessionHostDown and sends nothing, never falls back to a session of its own. The hook's account line shows the open session (pid, since, calls). LOG_TWITTER_HOST=inline runs the old way, for tests only.

Rule 106. 2026-11-01 · matt: "when doing stuff like changing the code or anything, ensure that there hasn't been any changes to it locally, like to the engine ... you're not allowed hand-rolled commands, it always has to be through the MCP basically". Two things, enforced by the hooks: - Through the MCP only. Every log action an agent takes goes through the log MCP tools (mcp__log__…), never a hand-run log … or bun bin/log.ts … from a shell. The PreToolUse hook (liveLogs, handRunDenial in src/agentHooks/liveLog) refuses one and names the MCP instead. Only the terminal-only prompts (TERMINAL_ONLY: launch, login, mcp, chat pin, cookies save, messenger pin) are run in a terminal, by matt. A stale MCP server is said to matt (restart the app), never worked around. - Local changes first. Before changing code, check what is already changed and uncommitted in the checkout. The engines hook says it loudly at session start and whenever it changes (>>> UNCOMMITTED LOCAL CHANGES, localChangesLine in src/mcp/freshness): read each one's diff, and never overwrite, revert, stage, publish or ship someone else's uncommitted work without matt.

Rule 107. 2026-11-01 · matt: "the queue connects to the one process, and it can do these at the same time, yet it's only on one". An account has one queue, and everything it sends goes into it: the dms, the reply-guy replies and the answers to people who message, together (each item keeps its own action). log twitter queue add <queue> --targets <file> --action dm|reply|quote --by <who> puts more in, even while it runs: the run takes them in as it waits and fits each one between what is planned, at least the queue's minGap apart, and one due sooner than the send it waits on goes first. queue run --keepOpen keeps the run going once it has sent everything, taking in what is added, until it is cancelled. So one run sends all of an account's work through its one X session (rule 105), never a second queue or run beside it. src/twitter/queue (addToQueue, fitTimes, takeIncoming, mergeAdded).

Rule 108. 2026-11-01 · matt: "if something doesn't seem to be working, you need to fix it", "make sure it's always up to date", and "if someone responds to me ... you have full authority to be able to send a follow-up message to them if it is in the context of the right thing to do with that said conversation". Three things: - Fix it, don't report it. Something broken is never told to matt as a fact and left. Find the cause and fix it in the same turn. Only when the fix needs something from him, say exactly what (a restart, a login, a key, a yes), and say that first. - Nothing runs stale. The inbox hook runs a silent cleanup on every event: a queue lock whose run died is cleared (and queue run, add and nudge take it over themselves), and an inbox watcher started before the newest code is stopped and started again (stopStaleWatchers, clearDeadQueueLocks, src/inbox/watch). An X session host closes when any of its code changes (python/twitterApi, python/platformKit, vendor/twikit), even while an X Chat watch is open on it, so the next call runs the new code. A read receipt X Chat can't verify is a read marker, never a message (unopened_of, vendor/twikit). - Answer straight away. When someone answers matt, a reply that answers what they said and fits that conversation is drafted, gated and sent at once, then matt is told what they said and what went out. Only a meeting, a promise, a price or money, or a conversation that can't be placed waits for him. Hook times are in matt's own clock.

Rule 109. 2026-11-01 · matt: "before you do an action with someone, you need to figure out the context of that person ... if they liked something, if they commented on something, if they replied to them recently, if I send them a message outside of the tool". Full context at the time of sending, every time, through the tool: - Before drafting, log twitter dms --with <handle> (MCP twitterDms { with }) gives the whole thread, read from X so it holds what matt sent from anywhere, and what that person did on the account's posts (likes, retweets, replies with their words, quotes, mentions, follows), from a fresh notifications read (activityOf, src/twitter/dm/person). Someone who already did the action is thanked for it, never asked for it. - Right before each send, the queue run reads both again and holds the item (status held, heldFor saying what changed) when anything happened since it was drafted (draftedAt): a message either way, a like, retweet, reply, quote, mention or follow (contextChangesSince, contextChanged in src/twitter/queue/run). - A held item is redrafted from the new context, gated, and put back with log twitter queue edit <queue> --n --text --by (MCP twitterQueueEdit). A queue file is never edited by hand, and nothing is sent around the queue to get past a hold (rule 106).

Rule 110. 2026-11-02 · matt: "we need to be faster with the mcp commands ... why is it taking so long for something which is not even something that should take long". Reads on an X account are spaced 0.8 to 2.5 s after the last call (READ_SPACING_SECONDS: inbox, a person's thread, notifications and their pages, follows, followers), the way X's own app fires reads back to back when a screen opens; only sends keep rule 104's 4 to 11 s (SPACING_SECONDS). Still one call per account at a time through its one session (rules 104, 105). Reading a person's full context (rule 109) pulls notifications only when they weren't synced in the last 2 minutes (notificationsFresh). A queue run never sends two things closer than its minGap, and items added to it keep that gap.

Rule 121. 2026-11-03 · matt: "those accounts are too big, a person who is too large isn't worth it yet, sub 1500 is ideal". A reply goes only to an author with 1500 followers or fewer. Reply-guy targets come from the niche map under 1500 followers. Before a reply likes, follows or sends anything, the engine reads the author's follower count (author_too_big, MAX_AUTHOR_FOLLOWERS in python/twitterApi/engine.py), and a larger author gets nothing (authorTooBig). The queue marks that item skipped (TOO BIG) and goes on: it is not a refusal (rule 118). A reply matt asks for by name goes out whatever the author's size, with force (rule 86), as his reply to @sporadica (222k) did on 2026-11-03. Every reply that does go out likes the post and follows the author first. Spec: docs/specs/xQueue.md (REQ-QUEUE-15).

Rule 122. 2026-11-03 · matt: "bump discord to 120". A Discord account sends up to 120 messages a day, not 60 (this replaces rule 91's number; WRITES_PER_DAY, src/directApi/engine, handed to the engine's governor in each run's spec). Rule 94 still holds: a 429 or any refusal stops the run, and cold DMs to strangers stay a few a day, minutes apart. X keeps 240, LinkedIn 10, every other platform 40. Spec: docs/specs/writeBudgets.md (REQ-BUDGET-01).

Rule 123. 2026-11-03 · matt: "make it a rule all platforms need the web socket hooks stuff there". Every platform log-cli talks to gets the live hooks X and Discord have. Its account's one session holds the platform's own push channel open: X Chat's socket, Discord's gateway, Slack's wss-primary.slack.com. That is never a poll from a second client. The inbox hook starts that watcher for every account and puts each new message, mention and reply in front of the agent with what to do (>>> NEW MESSAGE), per session (rules 105, 108, 115). It also stops a watcher running older code, skips an account cooling off, and stops on any refusal (rule 94). A platform isn't done until its watcher is in the inbox hook (scripts/hooks/inbox.ts, src/inbox/watch), starting with Slack. Reddit and LinkedIn aren't built yet. Spec: docs/specs/agentHooks.md (REQ-HOOKS-12).

Rule 124. 2026-11-03 · matt: "on creation of a new platform we need to auto build anchors as well". log new platform <name> writes the platform's spec with the rest of the platform: docs/specs/<name>.md and src/specs/<name>/index.ts. The spec has one requirement each for the session, the engine, the platform's own answer recorded, and the wiring, each with its SubQ question. Every anchor is on what the command just wrote, so the spec gate (rule 117) proves the new platform from its first commit. As the platform grows, its new behaviour goes into that spec with its anchors. Spec: docs/specs/platformScaffold.md (REQ-PLATFORM-14).

Rule 125. 2026-11-03 · matt: "Check slack via cli it needs to be swapped to use curl_cffi same as X". Slack goes out looking like a browser, the way X (rule 81) and LinkedIn (rule 102) do. vendor/gabrielSlack reaches Slack, its web API, its cookie exchange and its websocket (wss-primary.slack.com), only through gabriel/core/transport.py: one curl_cffi AsyncSession made by new_session, pinned to one impersonation target (IMPERSONATE, chrome150), sending that target's own user agent (USER_AGENT, which the client's default metadata uses), never httpx, websockets, aiohttp, requests or a socket of its own. It keeps no cookies between calls: the d cookie is sent on each one. gabriel's own login CLI, which drove Chrome through httpx and websockets, is removed. python/checks/slackTransport.py maps every transport touchpoint by syntax tree (--map), rewrites what has a curl_cffi equivalent (--apply) and refuses the rest (--check, bun run check:slackTransport, pre-commit and pre-push). Spec: docs/specs/slack.md (REQ-SLACK-05).

Rule 126. 2026-11-03 · matt: every voice note he was sent or sent, transcribed with AssemblyAI, and "inside of the discord layer we have that and then the social media platforms at large, how can we detect when we have audio". Audio is detected once, in the shared message layer (python/platformKit/media.py, src/platformKit/media), never per platform: a content type of audio/* or video/ogg, an extension (.ogg .oga .opus .m4a .mp3 .wav .aac .webm), or the platform's own flag (Discord's voice-message flag 8192 with its duration and waveform, X Chat's audio media, Slack's slack_audio file). Every platform's messages keep their attachments, and an audio one adds (audio: <name>) to the text. Whatever is audio goes through one AssemblyAI path (log transcribe, MCP transcribe; src/media/voiceNotes): kept once by its file's hash in ~/.logCli/transcripts, written into the conversation store as (voice note: "<what was said>") so search, voice and SubQ see it, and done as they come in discordRead, twitterDms, slackRead and the inbox. Files come only through the platform's own engine and session (X's one session, Discord's sound search, Slack's own transport), and any refusal stops the run (rule 94). The key is ASSEMBLYAI_API_KEY or ASSEMBLY_AI_KEY (.env.local, ~/.buzz/.env.local or ~/filmcraft/.env.local), never printed. log new platform writes the attachments into a new platform's message shape, and check:platforms refuses an engine whose messages drop them. Spec: docs/specs/voiceNotes.md.

Rule 127. 2026-11-03 · matt: "the watermarking has began ... research the saying 'is wild' no one says that", then "B is the only right option ... we're writing across everything i said and how i write it", and "install this and add it to the subq" (novatechflow/ai-watermark-scrambler). The voice gate passed "yc and a pageant in the same week is wild" because nothing compared a draft with what matt has actually written. His 66,116 messages never say "is wild", and another account's reply had used the same line word for word three hours earlier. Now the gate also measures his phrasing (src/voice/phrases): every reply in the voice index is cut into word pairs, a draft's share of pairs he has written at least twice (leaving out words from what they said) is set against his own 10th percentile, measured over his replies with each one's own pairs left out (phrases.json, built with the voice index), and a draft below it fails with the pairs he never wrote named. The vendored scrambler (vendor/aiWatermarkScrambler, Apache-2.0, layer A inspect_text.py) reads every draft for invisible and lookalike characters, and a hit fails it. Both readings go to SubQ with the draft. Its layer B rewrite needs a local model (Ollama or vLLM), which this machine doesn't have, so it isn't wired. Spec: docs/specs/voiceGate.md (REQ-VOICE-07, 08).

Rule 128. 2026-11-04 · matt: "you're not meant to open up the browser", after a meme went to someone on Instagram through the account's AdsPower browser. Instagram is worked through the direct-API engine on vendor/instagrapi, over the MCP, and the browser is never used for an Instagram action: log instagram dms reads the threads or one thread, log instagram send sends text and photos (a meme goes as --photo, a dry run unless --live with --approvedBy), and log instagram watch holds Instagram's own realtime connection (MQTT to edge-mqtt.facebook.com, the DM sync subscribed), which the inbox hook starts for every signed-in Instagram account so each new DM reaches the agent (>>> NEW INSTAGRAM MESSAGE, rule 123). An action the engine doesn't have yet is built on it, never done in the browser. Every HTTP call goes through curl_cffi: instagrapi's private and public transports are both set to curl (curl-adapter for the public one), and check:instagramTransport (pre-commit and pre-push) refuses anything else; the realtime MQTT socket isn't HTTP and is the one exception. Spec: docs/specs/instagram.md (REQ-INSTAGRAM-05 to 08).

Rule 129. 2026-11-04 · matt: "it will always be one continuous [session] so we can't have cold start stuff ... we can't open multiple python processes for this because that is going to fuck up our accounts". An Instagram account has one session, always, the way an X account does (rule 105). Every Instagram call (CLI, MCP, the inbox hook's watch) goes through the account's one session host (python/instagramApi/host.py, started on the first call, closed after 30 idle minutes or when the engine or instagrapi changes): one process, one instagrapi client signed in once from the saved sessionid and reused by every call after (reused in its answer), and one realtime connection to edge-mqtt.facebook.com that every watch listens on. Calls other than the watch run one at a time. A second host can't start (host.lock); a call that can't reach the host is refused with instagramSessionHostDown and sends nothing, never falling back to a session of its own. Every answer carries instagramSession (pid, calls, since). LOG_INSTAGRAM_HOST=inline runs the old way, for tests only. Spec: docs/specs/instagram.md (REQ-INSTAGRAM-09).

Rule 130. 2026-11-04 · matt: "we need to make it so we have a monitor on there that will then listen for the new info coming over via the websocket, because look, he responded halfway through ... he just asked something, it breaks the flow". On 2026-11-03 Abel wrote "is this ai" on Discord while an 8-line send was going out, and lines 6 to 8 went on top of his question. A message sent one line at a time stops the moment they write: before each line after the first, the send reads what the platform's push channel put in the inbox for that chat since the send began (incomingSince, src/inbox/watch, from pending.jsonl), and if they wrote, the rest is held and the answer leads with >>> INTERRUPTED: what they said and the lines not sent (interruptedLines). It holds for log discord send, log slack send, log twitter dm (old DMs and X Chat), the queue's dms and log instagram send (in its engine). What was sent stays sent, nothing is resent, and they are answered first (rule 109). An agent following a conversation keeps a Monitor on ~/.logCli/inbox/pending.jsonl so each push wakes it. Spec: docs/specs/messageSplit.md (REQ-SPLIT-08).

Rule 131. 2026-11-04 · matt: "WE NEED TO LOUDLY ALERT THAT THIS CANNOT HAPPEN ... WE CAN ONLY HAVE ONE PROCESS PER ACTUAL THING". On 2026-11-04 every watcher was running two or three times (X Chat for both X accounts, Discord and Slack): each new Claude session's hook started them again, the cleanup only killed the pid in watch.pid, which the new one had overwritten, and the doubled X watchers polled X's badge counts in bunches until X answered 429 on both accounts. One watcher process per account per platform, always, and every Claude session subscribes to it through the inbox (pending.jsonl), never by starting its own. Each watcher takes watch.lock (exclusive create, its pid) before it opens anything (claimWatcher, src/inbox/watch); a second one is refused with watcherAlreadyRunning and exits, and the refusal goes to ~/.logCli/inbox/alerts.jsonl, which the inbox hook puts first in the agent's context (>>> ONE PROCESS PER ACCOUNT, NEVER TWO) to be told to matt. A dead holder's lock is taken over, only the holder releases it, the hook never starts a watcher while a live one holds the lock, and the stale-code cleanup stops the holder as well as the pid file's process. Tests run the Instagram engine inline (LOG_INSTAGRAM_HOST=inline in the preload) so they never leave a real session host behind. Spec: docs/specs/agentHooks.md (REQ-HOOKS-13).

Rule 132. 2026-11-04 · matt: "this is what I expect to happen with all platforms": a singleton primary with an exclusive lock and a multi-subscriber relay. On every platform (X Chat, Discord, Slack, Instagram) the first watch to take an account's watch.lock is its one owner (ownOrAttach, src/inbox/relay): it opens the platform's push connection, writes session.json (pid, hostname, started, a heartbeat every 5 s, status connecting, streaming, reconnecting or error, its relay port and token, and how many are attached), writes the inbox (pending.jsonl) as its only writer, and sends every event to every attached subscriber over its local relay (127.0.0.1, the token first). log <platform> watch is the attach command: when an owner is live, a watch attaches to its relay and streams the same events without opening anything, so the hooks, a Monitor, Codex or another agent all see one stream; an inbox watch (--inbox) that loses is refused loudly (rule 131). An owner whose heartbeat is quiet for 60 s is stopped and alerted (>>> ONE PROCESS PER ACCOUNT, hungOwner), and the next start becomes the owner; when the owner closes, its session file goes with it. Spec: docs/specs/agentHooks.md (REQ-HOOKS-14).

Rule 133. 2026-11-04 · matt, to deepman: "whenever the agent wants to perform an action based on the streamed event information, it must first claim the relevant event ... only the session that successfully claims an event should be allowed to perform the corresponding action. Once the action has been executed through the state machine, the lock is released ... every time an action is executed, the system should also index and persist the associated information". Every message a push channel brings in has an event id (eventIdOf, shown in the hook's >>> CLAIM BEFORE YOU ACT beside who holds its chat). A session claims a chat before it drafts (log inbox claim, MCP inboxClaim, 10 minutes, renewed by claiming again; claimChat, src/inbox/claims, an exclusive claim file per chat, the session being its MCP server's process), and a second session is refused (chatClaimedElsewhere). Every send (log discord send, log slack send, log twitter dm, the queue's dms, log instagram send) claims the chat itself first (guardSend) and releases it once it is sent, interrupted or failed, so two sessions never answer the same chat. An X chat is one chat whether it is named by the person's id or the X Chat pair. A claim held by a session that ended, or past its time, frees itself. Every claim, renewal, refusal, send, interruption, failure and release is written to ~/.logCli/inbox/actions.jsonl, read with log inbox actions --chat <id> (MCP inboxActions) before acting; log inbox claims lists who holds what. Spec: docs/specs/agentHooks.md (REQ-HOOKS-15).

Rule 134. 2026-11-04 · matt: "Yes, go", moving the inbox's state out of loose files into one SQLite database, ~/.logCli/log.db (LOG_DB moves it; src/inbox/store, WAL, every write in a BEGIN IMMEDIATE transaction). The owner appends every event its push channel brings in to events with a sequence number, once (appendEvent, by its event id), and each session reads from its own cursor (cursors), so nothing is shown twice or lost between sessions; pending.jsonl is imported once and no longer written. An event type nobody has catalogued is stored with its raw payload and raised loudly (unknownEventType, eventTypes), to be named from rea, the docs and SubQ (rule one). The one owner per account (rules 131, 132) is a row in owners (pid, hostname, heartbeat, status, relay port and token, subscribers, a generation that rises with each new owner), never session.json, and a relay subscriber that falls more than 1 MB behind is dropped rather than slowing the owner. Claims (rule 133) are rows in claims, each with its chat's next generation, and a send checks before every part after the first that its claim is still the one held (stillHeld); a session whose claim was taken over stops, and the rest is held. Every claim, send and release goes to actions in the same transaction as the change it records, with what was done in the chat before it (stateBefore), and what was done is searchable with the events (searchIndex, FTS5). Each event's moments are timed (timings: received, appended, relayed, claimed, actionStart, confirmed, indexed). Alerts are rows in alerts. Spec: docs/specs/agentHooks.md (REQ-HOOKS-16).

Rule 135. 2026-11-04 · matt (through his side session): missing context "is filled by catching up, never by every caller re-reading the client". Each account's owner keeps the platform's own last position in the .db (positions: X Chat's sequence id, Discord's last message id per DM, Slack's last ts per chat, Instagram's last activity). A gap opens (gaps: from, to, reason, status) when an owner takes over from a stopped or dead one (from its last heartbeat), when the first owner starts on an account that already has events (from the newest), or when the push channel drops. While a gap is open the account is behind, and one catch-up job inside the owner (catchUpRunner, src/inbox/catchUp) pulls only the delta through the account's own session and governor, paced like reads (0.8 to 2.5 s): X's delta sync after its saved sequence id, Discord's DMs after each one's saved message id, Slack's history after each chat's saved ts (oldest), and Instagram's threads active since its last activity. What it pulls is appended to events like live pushes, marked source: catchUp, and the gap closes. A send (Discord, Slack, X dm and the queue's sends, Instagram) waits up to 90 s for the account to catch up (waitCaughtUp) and never reads the platform itself; still behind, it is refused with accountBehind. A refused catch-up (a 429 or any refusal) stops, keeps the account behind, alerts (catchUpRefused), and nothing resumes until matt says so (rule 94). Discord's gateway RESUME is not used yet. Spec: docs/specs/agentHooks.md (REQ-HOOKS-17).

Rule 136. 2026-11-04 · matt: "these must be completed", with the direction that the context before a send comes from the .db, not from a fresh platform read. This changes rule 109's last read: right before each queued send, the run reads that person's messages both ways and what they did on the account's posts from the .db (chatContextOf, src/inbox/context), once the account has caught up (rule 135), and holds the item when anything happened since its draft, as before. X is not read for it. The platform is read only by the catch-up job, by backfill (history from before the push channel existed) and by discovery (unknown event types). Every owner stores the account's own messages (ownMessage) as well as theirs, and X's notifications arrive through the owner's badge poll (rule 115). Spec: docs/specs/fullContext.md (REQ-CONTEXT-04, 10).

Rule 137. 2026-11-04 · matt: keep every part of the voice gate ("necessary") and run it "as the whole voice process on the new .db", required inside every send, only at send time, scoped to that send, quiet; then "Yes, both" to jina-embeddings-v5-omni and the local media model. The voice gate is one step (gateSend, src/cli/twitter/helpers/voice.ts) inside log discord send, log slack send, log twitter dm, the queue's sends and log instagram send, after the account has caught up (rule 135) and before the chat is claimed (rule 133): a draft that fails is refused with voiceGateFailed whether or not log voice ran first, and in the X queue the item is held to be rewritten with twitterQueueEdit. All five checks stay: SubQ's read of matt's voice, the AI tells index with its reranker (rule 112), his own phrasing (rule 127), the watermark checker (rule 111) and the scrambler (rule 127). It reads the chat's context from the .db (rule 136), embeds it, ranks it against the moment being answered (cosine, then jina-reranker-v3.5) and gives SubQ the top moments in full (rankedContext, thisConversationRankedForThisMoment). Only embeddings are kept, in the .db (embeddings: the text's hash, model, dimensions and vector, each made once), with the AI tells index's phrase list and matt's baseline (aiIndexes); every result is worked out fresh for each send, never saved. Jina is jina-embeddings-v5-omni-small at 1024 dimensions (text-matching), and an index made with another model is rebuilt. The local embedder stays loaded in its process. A pass is one line; a fail names only what failed and why. Spec: docs/specs/voiceGate.md (REQ-VOICE-09 to 11), docs/specs/aiTells.md (REQ-AITELLS-05, 10, 12).

Rule 138. 2026-11-04 · matt: "a smaller local model" turns every piece of media into "a text representation", labelled with what it is, after a message went out answering only the text around a video someone sent; then "Yes, both" to downloading it. One media describer per machine (log media describer, src/media/describer), started by the inbox hook and locked like an owner (rule 131, a second is refused), loads onnx-community/LFM2.5-VL-450M-ONNX once (SubQ's pick, q4, on the transformers.js runtime log already uses) and keeps it warm. Every image, meme, gif and video a watch receives goes into the .db's media queue (enqueueAttachments). The describer takes one at a time, describes a video from three frames (ffmpeg), labels each description with its kind, stores it with its jina-embeddings-v5-omni-small vector, beats every 5 s, and stops loudly past its memory ceiling (describerMemory). The queue holds 20 at most: past that, an item is marked not described yet and alerted (mediaQueueFull). Media with no description is shown as [video clip.mp4 not described yet: …] in the inbox hook and in the voice gate's ranked context, so nobody replies to it blind; a description reaches both in its place. Audio stays with AssemblyAI (rule 126). A file the platform engine hasn't saved locally yet is listed as not described, with why. log media describe <path> queues one local file, and log media status shows the queue. Spec: docs/specs/mediaDescriber.md.

Rule 140. 2026-11-04 · matt: "we're able to send reactions as well, across all of the clients. We need to be able to send reactions to messages as well." Every chat platform reacts to a message with an emoji through its own engine and its one session, never the browser: log twitter react, log discord react, log slack react and log instagram react (MCP twitterReact, discordReact, slackReact, instagramReact, each with its …Live twin), --remove taking the account's own reaction off. Each calls the platform's own reaction call: X's DM reaction mutation in vendor/twikit (an X Chat id is tried as the same pair, and X's answer decides), endcord's send_reaction, Slack's reactions.add (by name; the common emoji are turned into their names, python/platformKit/reactions.py) and instagrapi's direct_send_reaction. A dry run unless --live with --approvedBy (rule 8). The chat is claimed first and released after, as for a send (rule 133), the governor counts it as a write, the platform's answer decides (rule 73), and a refusal stops with nothing retried (rule 94). src/cli/react. Spec: docs/specs/reactions.md.

Rule 142. 2026-11-05 · matt: "make it so that we can send audio, like upload natively into the platform, voice notes ... directly into the actual clients themselves ... we need to voice clone ourselves from audio ... it will then be an extension, like we have for the meme board stuff", with Fish Audio (hosted) his pick. A voice note only ever goes out when matt asks for one, in his cloned voice, as the platform's own voice note: never on the agent's own initiative and never any other audio. Fish Audio is an extension (src/extensions/fishAudio, docs/extensions/fishAudio.md): log fish clone makes his voice model once from the voice notes he sent himself (fromMe in the conversation store or his ownMessage in the .db; Discord voice messages and Instagram voice clips the engines kept, 10 to 60 s in all; nobody else's voice), a dry run listing them unless --apply, its id kept in ~/.logCli/voice/fishClone.json; log fish speak --text says words in it to a file under ~/.logCli/voiceNotes and sends nothing, on the free s2.1-pro-free model unless --model. log discord voice, log instagram voice and log slack voice take text only (an audio file is refused with voiceNoteInvalid), are dry runs unless --live with --approvedBy (rule 8), wait for the account to catch up (rule 135), run the text through the voice gate (rule 137), make it with the clone, convert it with ffmpeg (Discord ogg/opus, sent as a voice message with flags 8192, its waveform and duration; Instagram AAC in .m4a through instagrapi's direct_send_voice, its upload on curl_cffi under the one-call lock, rule 129; Slack AAC in .m4a through files.getUploadURLExternal, the upload on gabriel's curl_cffi transport and files.completeUploadExternal, rule 125), claim the chat (rule 133), count as a write, and stop on any refusal (rule 94). An engine sends only a file log made under ~/.logCli/voiceNotes. X isn't built: twikit has no audio send and no vendored code or doc shows X's DM voice upload, so nothing is guessed. src/cli/voiceNote. Spec: docs/specs/voiceClone.md (was fishVoice.md).

Rule 143. 2026-11-05 · matt: "the voice cloning and the voice note stuff will be agnostic", after Fish Audio refused the clone without API credit (403, model creation limit 0/0) and he had none to put in. The voice layer is provider-agnostic, the way proxies are (rule 52): src/extensions/ voiceClone holds one interface (its models, cheapest model, clone limits, the reference format it takes, clone and speak), and each provider is an extension of its own: src/extensions/inworld (the default: instant cloning free, about 70 free speech minutes, wav references 3 to 30 s, inworld-tts-2-flash, INWORLD_API_KEY) and src/extensions/fishAudio. The provider is --provider, else LOG_VOICE_PROVIDER in .env.local, else inworld. log fish clone|speak became log clone voice|speak (MCP cloneVoice, cloneSpeak); log fish is refused with the form it became (rule 63's pattern). Each provider's clone is kept in ~/.logCli/voice/clone.json, keyed by provider, made once. log discord|instagram|slack voice take --provider and call only the shared layer, never a provider. Everything else in rule 142 holds. Spec: docs/specs/voiceClone.md.

Rule 144. 2026-11-05 · matt: "bro fuckign send th efuckign shit", after the voice gate refused three times the voice note he dictated to deepman word for word ("my phone screen is broken the phone is fine like theres two big green lines going down the side of it and that but it still works thank the lord"). The gate grades speech as typed text and as a reply (voice 0.06 to 0.48, inContext 0.21), so it can't read a voice note in his own spoken words. --mattsWords on log discord|instagram|slack voice (MCP mattsWords) says the text is matt's own words, dictated by him for this voice note: the voice gate's SubQ read is skipped, and the AI artifact check (watermarkReadingOf) still runs and refuses aiArtifacts. Only when matt gave the exact words; words the agent drafted are always gated (rule 137). Sent live 2026-11-05 08:04 UTC, Discord message 1567811929500811294. Spec: docs/specs/voiceClone.md (REQ-VOICE-CLONE-10).

Rule 145. 2026-11-05 · matt: "Yes, re-clone it", because the Inworld clone was made with languageCode en-US, which pulled his voice towards an American accent, and he is Northern Irish. The voice is cloned and spoken in his accent: LOG_VOICE_ACCENT (.env.local, default Northern Irish) and LOG_VOICE_LANGUAGE (default en-IE, sent again as en-GB when Inworld answers INVALID_ARGUMENT). The clone sends that language, a description naming the accent and each sample's AssemblyAI transcript from ~/.logCli/transcripts when there is one (none is paid for: TTS-2 makes its own), and keeps the language Inworld accepted. log clone voice --apply --replace (MCP replace) makes it again over the old one, which stays in the clone file under previous, never deleted. Speech sends the clone's language; on inworld-tts-2 the text starts with [say it in a <accent> accent], an instruction Inworld removes before synthesis, and inworld-tts-2-flash, which ignores steering, gets none. Spec: docs/specs/voiceClone.md (REQ-VOICE-CLONE-11). (Its accent steer was replaced by rule 147, matt 2026-11-05.)

Rule 146. 2026-11-05 · matt: "the second one but why does it speak so slowly" and "use the discord audio for the samples". The clone speaks at LOG_VOICE_SPEED (.env.local, default 1.15, kept between 0.8 and 1.5), sent as Inworld's speakingRate, and a clone made on Inworld speaks with inworld-tts-2 (the provider's preferred model, which takes emotion tags), not the cheapest. log clone voice --from <file>[,<file>] clones from recordings of matt's own that he names instead of his voice notes, each one's words read from a .txt of the same name beside it. Spec: docs/specs/voiceClone.md (REQ-VOICE-CLONE-12).

Rule 147. 2026-11-05 · matt: "ITS IRISH", none of the accent-steered clips sounded like him, "11_script_long272_b ... was pretty good", then "go through my voice notes ... analyse it", "it still doesn't have that distortion", and "op is real like op not o p ... make it the gate for the voice ... or it won't pass". A voice note sounds like his own voice notes and says what was written, measured both times: - The clone is Inworld instant cloning from one continuous 30 s of his own Discord voice note (matt-long272_b, 2:30 into his 4.5 minute note), the accent Irish (LOG_VOICE_ACCENT). No accent instruction is sent with the text: the clone carries the accent. On inworld-tts-2 breaths are put in ([breathe] at the start and once at least 8 words have gone by, withBreaths). - Shaped like his notes (processLikeMatt, src/extensions/voiceClone/helpers/realism.ts), every number measured from five of his real Discord voice notes against the clone: phone squash (dynaudnorm), the phone mic's own tone curve measured from his notes (REALISM.phoneCurve: its low cut under 160 Hz, its dip around 2 to 3 kHz and its dull top over 5 kHz), his slow mic drift (1.9 dB), tone sway as the phone moves, phone-mic saturation on loud words, and his own room tone taken from the gaps in his own voice notes (never a sound effect), laid 22 dB under the voice so it lands where his do (30 to 33 dB). Seeded by the text, so the same words come out the same. - Listened back (readSpoken, spoken.ts): the shaped note is transcribed with AssemblyAI's word timings and lined up with the text. A word in the spoken lexicon (SPOKEN_LEXICON: op said as a word, written OP for the clone, since a lower-case op came out as "up") must be heard as itself, as one word under 400 ms; spelled out, heard as something else, or more than a quarter of the words different, and it is refused with spokenMismatch, naming what was heard. An acronym the lexicon doesn't have is refused before anything is spoken (pronunciationUnknown): how matt says it is never guessed. A note that can't be listened back is never sent (spokenCheckFailed). It runs in log clone speak and every log discord|instagram|slack voice. This replaces rule 145's accent steer. Spec: docs/specs/voiceClone.md (REQ-VOICE-CLONE-13, 14).

Rule 148. 2026-11-05 · matt: "send it in chinese aswell", then "ADD A BYPASS PERMISSION FOR IT BECAUSE OFC IT WONT MATCH UP", after the voice gate scored the Chinese translation of a voice note it had passed in English at 0.13: it compares a draft with matt's own writing, which is English. --translationOf "<the original>" on log discord|instagram|slack voice (MCP translationOf) says the text is a translation matt asked for: the voice gate grades the original words instead of the translation, both are read for AI artifacts, and it needs --language <code> (zh-CN, es-ES ...), the language the clone speaks it in instead of the clone's own (Inworld's language). With en-IE the clone said the Chinese with two words wrong; with zh-CN it said them right. It is only for a translation matt asked for, never paired with --mattsWords. The listen-back check (rule 147) reads Chinese, Japanese and Korean one character at a time (wordsOf), so a translated note is checked too. Spec: docs/specs/voiceClone.md (REQ-VOICE-CLONE-15).

Rule 149. 2026-11-05 · matt: "it keeps doing this weird exhale at the beginning ... the lengths of the things are always like 5 seconds", then "READ THE CONVERSATION ... I'M ASKING YOU TO RESPOND AT LENGTH ... BUILDING ON WHAT HE'S SAYING AS A 40 SECOND VOICE NOTE IN ENGLISH, YET YOU'RE JUST RE-EXPLAINING SOMETHING I ALREADY SAID". Three things: - No breath at the start. withBreaths never opens a note with [breathe] (that was the exhale). A breath goes in only between sentences (.!? and the Chinese 。!?), after a gap of 8 to 18 spoken words drawn from the text's own seed (BREATH_WORDS; a Chinese character counts half), so no two notes breathe alike. - The pace drifts. Each note is spoken at LOG_VOICE_SPEED moved by up to 7% from its text's seed (paceOf, PACE_JITTER), within the speed range. - A voice note is written at the length matt asks for and builds on the whole conversation. Read the whole chat first, then write to the length asked (a 40 s note is about 100 to 110 words), taking what they are talking about further and adding to it, never re-explaining what matt already told them. Note lengths vary: not every note is three short lines. The voice gate grades a voice note as spoken (spoken, set by every log discord|instagram|slack voice and by log voice --spoken): its length and punctuation never count against it, offProfile (his typed habits in the chat) isn't applied, and a new question, buildsOn (0.7 to pass), refuses a note that repeats what matt already said or ignores what they are talking about. Its voice question reads how matt actually talks first: the transcripts of his own voice notes from before the clone existed (howMattTalks, mattsOwnVoiceNotes), never his typing alone and never a note the clone made. Spec: docs/specs/voiceGate.md (REQ-VOICE-13), docs/specs/voiceClone.md (REQ-VOICE-CLONE-16).

Rule 150. 2026-11-05 · matt: "Yes, resume both", after the catch-up on matthewsoldit (X Chat's PIN unlock, Juicebox "Transient error") and on Discord was refused and every send was held with accountBehind. Rule 94 said nothing resumes until matt says so, but nothing could take his word. log inbox resume --platform <p> --account <a> --by matt (MCP inboxResume) is that word: a refused gap opens again with who resumed it and what was refused before (resumeGap, alert gapResumed), and the owner, which now looks at its gap every 30 s (RECHECK_GAP_MS), catches up again through its own session. Only when matt says so, never on the agent's own; a second refusal stops it again. And so the X voice note can be read from X's own client (rule 113), log twitter session webClient also saves X Chat's own app, which x.com only frames: it reads chat.x.com/embed-v1.json, the build's remoteEntry.js and every script the manifest lists (English only), spaced as reads through the one session, into the save's xchat/ folder (save_xchat_build). Spec: docs/specs/agentHooks.md (REQ-HOOKS-18), docs/specs/xSession.md (REQ-XSESSION-15).

Rule 151. 2026-11-05 · matt: "build the voice nots on X from X's own client so we'll figure out how it's done via the bundle itself ... and then we'll add it into the vendor itself", then "Yes, build it". An X voice note is an X Chat voice message, sent the way X Chat's own app sends one, read from its own code: log twitter session webClient now saves X Chat's whole app, following each script's imports (rule 150), so its engine, dm-core (Kotlin compiled to JS), is saved too. In it: the recorder makes audio/mp4; the file is encrypted with the conversation key (chat-xdk encrypt_stream); InitializeXChatMediaUploadMutation (6M543IPabOfZm7-yyIczdw) answers a media_hash_key and resume_id; the parts are POSTed as application/octet-stream to ton.x.com/i/ton/data/xchat_media/<conversation>/<media_hash_key>?concurrent=true&resumeId=<id>&partNumber=<n> from 0; FinalizeXChatMediaUploadMutation (3O4jPQ2hwW9LbqCc-wFmqA) closes it; and SendMessageCreateMutation (TWRPP7gnKwV_R8-tE-Dd3Q) sends chat-xdk's encrypt_message (its encrypted_content and encoded_event_signature) with one attachment, media type AUDIO (4) and duration_millis. This is in vendor/twikit (xchat_latest_key, upload_xchat_media, send_xchat_message, send_xchat_voice; the operations in client/gql.py), so twikit can now send in X Chat. log twitter voice --account <a> --with <handle|id> --text "…" (MCP twitterVoice, twitterVoiceLive) is the voice note command on X, the same as on Discord, Instagram and Slack (rules 142 to 149): made in matt's cloned voice, AAC in .m4a, gated, claimed, a dry run unless --live with --approvedBy, and sent by the engine action xchatVoice through the account's one session, X Chat opened with the saved PIN (rule 88; xchatPinMissing when there is none). The engine sends only a voice note log made under ~/.logCli/voiceNotes. chat-xdk mints the message's own id, so the upload carries an id of its own, as X's official upload takes none. This replaces rule 142's "X isn't built". Spec: docs/specs/voiceClone.md (REQ-VOICE-CLONE-09), docs/specs/xChat.md (REQ-XCHAT-11).

Rule 152. 2026-11-05 · matt: "Send it anyway", after the voice gate inside log twitter dm refused a short dm to @AjeetVermai that he had asked for. The gate inside every send (rule 137) had no way to take his word over it. --overGate on log twitter dm (MCP overGate) is that word: the gate's SubQ read is skipped for that dm, and the AI artifact check still runs and refuses aiArtifacts (rule 111). Only when matt said to send that exact message over the gate's refusal, never on the agent's own, and never for a queue. The send says it went over the gate. The same day the gate found a person named only by user id by their handle (handleOfUserId), so it reads their chat. Spec: docs/specs/voiceGate.md (REQ-VOICE-14).

Rule 153. 2026-11-05 · matt: "say idk to matt in the groupchat", then "Yes, wire it". log sends text into an X Chat group: log twitter dm --userId g<digits> (the group's id) goes to the engine, which opens X Chat with the account's saved PIN and sends it with twikit's send_xchat_message (xchat_group_text), the way X Chat's own app sends (rule 151). With no PIN it is refused xchatPinMissing. A person's id must still be digits, and only a dm takes a group id. log twitter voice --with g<digits> sends a voice note into the group the same way, and it was sent there live on 2026-11-05. X Chat ties a media upload to the message that carries it: the upload and the message share one message id (chat-xdk's encrypt_message takes message_id, rebuilt into vendor/chatXdk/wheels), Initialize is sent without a content type, and each part is posted to the resume_upload_url Initialize answers, as raw application/octet-stream with the bearer, the auth_token and ct0 cookies and the CSRF token (stephancill/stupid-social's working client). Building the URL ourselves was refused 401 every time. Spec: docs/specs/xChat.md (REQ-XCHAT-12).

Rule 154. 2026-11-05 · matt: "no ever showing me UUIDs". Nobody is ever shown to matt as an id, on any platform: every person in a list, a thread, a hook line or a write-up is named by their username (and display name where the platform has one). An id with no name is looked up through the account's own session before anything is shown (X's user lookup, Discord's, Slack's and Instagram's own), the name is kept so it is never looked up twice, and one that still can't be named is listed with why, never shown as the bare id. On X, name_people names every person in the inbox list from any conversation that already knows them or from X's lookup, and remember_handles keeps them; twikit reads a user's name from X's newer core block. A platform isn't done until its lists name everyone. matt then: "make it as part of the state machine". Naming is a stage of every event's life in the inbox (named, between appended and relayed): an event that names its sender teaches the .db's people table who that id is (rememberPerson), an event that arrives with only an id is named from it (personOf) before it is stored, and every label read back for matt goes through namedLabel, which never returns an id: one nobody has named yet reads "someone not named yet". Spec: docs/specs/xChat.md (REQ-XCHAT-13), docs/specs/agentHooks.md (REQ-HOOKS-19).

Rule 155. 2026-11-05 · matt: "who the fuck is it reading to ... we need a context for the voice notes as well, like we do for the sends, across all of the voice notes, a unified system", after a voice note for the #general channel of paulchrisluke's server was written to Paul ("you just got back on X") when nobody but matt had typed there since 11 October; and "it hasn't got any context, it doesn't show what channel it's in". Three things: - The room. Every send and voice note gives the voice gate the same picture of where it goes (roomOf, src/voice/room): the chat's kind (a DM, a group, a server channel), who else has spoken in the last 24 hours, when the last of them did, how many of matt's messages end the chat, and who it must be addressed to (addressTo). Discord reads the chat's last 20 messages through the engine for it (roomFromRead, inside the gate, readRoom); every other platform builds it from the chat in the .db. SubQ reads it as theRoom and answers fitsTheRoom (0.7 to pass): a channel or group where nobody else has spoken lately is talked to as a whole, never one person as if they were mid-talk. - Chats by name. A Discord, Slack or Instagram tool call that names its chat by a bare id is refused by a PreToolUse hook (scripts/hooks/chatNames.ts, chatNameVerdict) and told to pass the name, so matt sees where every call goes: a person, a group, or a server channel as "<server> #<channel>". - Servers are found, never asked for. The Discord watcher saves every server and text channel the account is in from the gateway's READY (save_servers_from), and the chat list includes them, so "paulchrisluke #general" is matched like a DM. A cold read of the list reads each server 1.5 s apart and skips one that doesn't answer. Spec: docs/specs/voiceGate.md (REQ-VOICE-15), docs/specs/agentHooks.md (REQ-HOOKS-20).

Rule 156. 2026-11-05 · matt: "let's do some cold outbound sending them their tweets then send a voice note after like a real brief one and then just literally lock that in". Cold outbound on X is two touches through the account's one queue (rules 104, 107): first the opener (their own recent tweet forwarded, then one line about it, rule 98), then a brief voice note in matt's cloned voice that goes one step further, a question about what they posted, never the ask. The voice note is a queue action of its own, voice (QUEUE_ACTIONS, toAPerson): its text is said with the clone (voiceNoteDeps.render), gated as speech (spoken), and sent as an X Chat voice message by the engine's xchatVoice with the saved PIN (rules 151, 153), its chat claimed like a dm (rule 133). A dm or voice item keeps the tweet it opens on (postText), and the send-time gate judges it against that tweet (said), as it does a reply's. The voice notes go into the same queue once the openers have gone out, so each lands after its own opener. Targets come from the niche map: mutuals under 1500 followers with a recent original post, not already talked to (rules 99, 121, 87). Spec: docs/specs/xQueue.md (REQ-QUEUE-16).

Rule 157. 2026-11-07 · matt: "a lot of the time I notice this whenever you make a queue, all of the things and the messages in the thing are exactly the same ... if you put a haha and you say that and then it reads like me", and "the guides are not being consistently called for actions ... it's meant to re-inject the context into it so then it guides it in the right way". On 2026-11-07, 32 share-asks went out from @matthewsoldit: the 18 to people he talks to each ended "just put this out <link> / could you share it its open sourced haha" with only the first line changed, and 14 new followers got one line word for word, while the play he meant (reply to their post with a meme, forward that post as the opener, wait, build on what they say, then the ask) was never loaded. Two things: - No two messages read alike. A send is refused with sameAsSent when half or more of its word pairs (links left out) are already in a message the account sent anyone in the last 24 hours, read from the .db (sameAsSent, src/voice/sameness), and queue check refuses a queue item that repeats an earlier item the same way. The voice gate reads each message alone, so a "lad" and a "haha" passed a run of near-copies; this runs before the gate, so overGate doesn't skip it. - The play stays in context. Loading a use case with getGuide({ useCase }) makes it the MCP session's play: every log tool's answer ends with the step it is and the step that comes next, from the page's own steps table, and one the play doesn't use says so (src/mcp/server/helpers/activePlay.ts). A send as matt (twitterDmLive, twitterReplyLive, twitterQuoteLive, twitterQueueRunLive, twitterVoiceLive, discordSendLive, slackSendLive, instagramSendLive) is refused with playMissing while no play is loaded, naming the use cases that call it. Specs: docs/specs/voiceGate.md (REQ-VOICE-16), docs/specs/mcpServer.md (REQ-MCP-15).

Rule 158. 2026-11-07 · matt: "how do the guides get CALLED when I say something ... do i have to invoke it or is it inferred", then "we need to have a context engine ... one command that installs all of the skills ... the use cases need to be injected as well ... you don't even try to look ... you don't try to search ... I need to have variations". A use case was only ever found if the agent thought to look. Now it is put in front of it three ways: - Every use case is a skill. bun run skills:build (scripts/docs/syncSiteSkills.mjs) turns each docs/useCases/<platform>/<page>.md into the skill log-<platform>-<page>: its description is the page's title and lead, its when_to_use is the page's own "How the owner asks for it" (the title when a page has none, never words matt didn't say), and its body says to load the play with getGuide({ useCase }) first (rule 157). They are written to .claude/skills/ (so a session in this repo has them) and published at /.well-known/skills/, and install.sh --skills all (--skillsOnly for someone who already has log, LOG_SKILLS=all on Windows) installs every one. check:siteSkills refuses a skill out of step with its page. - The prompt is matched to a play. A UserPromptSubmit hook (scripts/hooks/useCases.ts, matchPlays in src/useCases/router) compares matt's message with every page's asks and title by content words, and when one scores 0.6 or more it puts >>> PLAY MATCH in the agent's context, naming the play (and a runner-up) and saying to load it with getGuide before anything else. Nothing is said when nothing matches. - A refusal says where to look. When the voice gate or sameAsSent refuses a draft, the refusal ends with the steps to rewrite it from evidence, not memory (rewriteSteps, src/cli/twitter/helpers/voice.ts): voice with no text for that person's thread and matt's real replies, search for what he has said on it before, then a draft unlike anything sent today. Specs: docs/specs/mcpServer.md (REQ-MCP-16), docs/specs/voiceGate.md (REQ-VOICE-17).

Rule 159. 2026-11-07 · matt: "we've got naming conventions, yet we don't have hook conventions ... someone's already made a router ... we need to be able to benchmark the re-injection ... a lean, fast P99, P95 injection layer ... I type very quickly with spelling mistakes". Before this, the word router from rule 158 matched 8% of his real prompts, mostly wrongly. It also fired on task notifications, and 8 bun hooks ran on every prompt (210 to 520 ms each). Now the context layer has conventions (docs/contextLayer.md) and runs as one service: - One warm service per machine. scripts/contextLayer/service.ts (src/contextLayer/service) keeps MiniLM and the route index loaded on 127.0.0.1:47317 (LOG_CONTEXT_PORT). The per-prompt hook is one curl to it (service on an AgentHook, serviceHookCommand), and the bun script after || only starts the service. - The router hears typos. It follows aurelio-labs/semantic-router's method (src/useCases/router): - each word is corrected against the playbooks' own vocabulary (correctTypos, one edit from 4 letters, two from 8); - a long prompt is read as overlapping 14-word windows; - each play is scored on its 5 closest asks, his own labelled prompts among them; - 70% meaning, 30% words. At 0.63 and above it names the play (>>> PLAY MATCH). From 0.4375 it lists the 3 closest, and the agent says which it follows or "none" before any tool (>>> MAYBE A PLAY). Below that it says nothing. Task notifications and system text are never routed (isPrompt). - The play comes back after compaction. SessionStart (startup, resume, clear, compact) reads the session's own transcript for the last play it loaded and the last step it ran (sessionPlayOf), then puts it back with each step marked done or to do (>>> ACTIVE PLAY, playReminder). - Benchmarked, never assumed. bun scripts/useCases/benchRouter.ts scores the router 5-fold on matt's 420 labelled prompts (~/.logCli/contextLayer/labelledPrompts.json, kept on matt's machine and never committed because the repo is public: his words, typos kept, 107 plays and 313 "none"). It reports plays caught, wrong fires on "none", the right play in the top 3, and p50/p95/p99. On 2026-11-07: - the old word matcher caught 21% of plays and fired wrongly on 7.3%; - the router caught 23% at 2.9% wrong fires, and had the right play in its top 3 for 64%; - p50/p95/p99 were 12/36/47 ms in the service, and the hook took 26 to 43 ms end to end. Every hook writes a timing line to ~/.logCli/contextLayer/timings.jsonl. A change that lowers plays caught or breaks the budget doesn't ship. Spec: docs/specs/mcpServer.md (REQ-MCP-17).

Rule 160. 2026-11-07 · matt: "it keeps rereading all of my conversations on social media. It shouldn't ever call that ... that's meant to read from the database ... If it needs to read it, it re-indexes and plays catch up ... or it's routed into just reading directly against the database, which achieves P99". On 2026-11-07, discordRead took p50 91 s and twitterDms p50 11 s (p95 69 s). Both went to the platform on every call, and every Discord lookup by name listed all its chats first, while each account's owner was already keeping the .db current. Now a read for one chat or person decides where its answer comes from (readSourceFor, src/inbox/dbRead): - the .db, when the owner is live, the account has caught up, and the chat has been backfilled once (markBackfilled) or the .db holds as many messages as asked for; - the platform, once, to fill the .db, when the chat was never backfilled, when no owner is live, or for a Discord server channel, which the watcher keeps only mentions and replies from; - a refusal, never a platform read, when the catch-up was refused (rule 94). log discord read and log twitter dms --with work this way. The X read decides before the X session is opened, and --full or --force still reads X. A Discord chat named by its name is found in the kept chat list (findInChatList, fresh for 6 hours), and Discord is listed only for a name the list doesn't have: read, send, delete, react and voice. Every read says where its answer came from. On the top 20 chats: - Discord from the .db: p50/p95/p99 of 0.6/1.7/2.6 ms, 12 of 20 already answerable; - X from the .db: 25/33/40 ms, 3 of 20 already, and each other one after its first read. bun scripts/inbox/benchDbRead.ts measures it. Spec: docs/specs/fullContext.md (REQ-CONTEXT-11).

Rule 162. 2026-11-07 · matt: "sometimes what you're doing is giving out a task. It's not the same thing as like casually talking with someone ... those way of doing it is rude. It's not clear. It's not informative", then "make a document, send the documents over ... We need to maybe make that". Two things: - A task is a brief. A message that hands someone work goes with --brief (MCP brief) on log discord send, and the gate reads it as a brief instead of matt's chat voice (gateBrief, readBrief, src/voice/brief). SubQ answers five questions, each 0.7 to pass: clear (what to do, in order, any term explained), why (what the work is for), doneWhen (what they hand back and by when), kind (warm and informative in matt's register, never curt, bossy or a bare order) and steerable (how to ask or say they're stuck), plus fitsTheRoom. It reads the chat's ranked moments and the attached files beside the draft. The sameness check and the scrambler still run. - Files go with it. --file <path>[,<path>] (MCP file) attaches files to the last message, uploaded through the account's own session (endcord's attachment upload, attached_files, python/discordApi/engine.py): only files under OUTBOX or ~/.logCli, at most 10 MB each, else fileRefused before anything is sent. The same day, the Discord and Slack catch-up stopped reading the .db's backfill markers (backfill:<chat>) as chats to catch up. Spec: docs/specs/voiceGate.md (REQ-VOICE-19).

Rule 165. 2026-11-07 · matt: "find the place inside of my code that are slowing it down", then "have all timing known and stored so that we're able to then add further optimizations", after twitterDms took p50 9.5 s while X answered in 0.69 s, and the live log had no call id, so two sessions' calls couldn't be told apart. Every call is timed, never guessed, through one timing layer for Bun and Python (src/observability/timing, python/platformKit/timing.py): - A call id on every call. Each MCP tool call and terminal run is one call. Its id and how long it took are on its ▶ and ■ lines in the live log. - Every stage is a span. Each span records its parent, start and length: the guards (the catch-up wait, the read source, the claim), each step of the voice gate, every fetch (SubQ, Jina, Twenty, ...), each Python engine run and the Python start-up before it. The trace goes into the engine's spec and through the account's session host: its queue, the account lock, the spacing, the governor, the client, the action, and every curl_cffi request (twikit, instagrapi, gabriel). The host sends its spans back in its answer, so one call is one tree. - Stored, kept 90 days. Spans go to log.db's spans table. With LOG_OTLP_URL set they are also sent as OTLP to a trace viewer (otel-desktop-viewer), with no OpenTelemetry package to install. - Read before saying it's slow or fast. log timing (MCP timing) gives p50, p95 and p99 per tool and per stage, with each stage's share of the time and the call's own time outside every stage. log timing --call <id> shows one call as a waterfall. Optimisation starts from those numbers, and the same report is run again after the change. Research: RESEARCH/LOG_CLI_CALL_TRACING_TOOLING_2026_11_07.md. Spec: docs/specs/callTiming.md.

Rule 166. 2026-11-07 · matt: "make it so that we can then call treg as an extension inside of this as well and then write it up into the extensions". treg is an extension like Memelord (rule 119), reached through log's own command and MCP tools, never by hand (rule 106): - log treg search "<job>" (MCP tregSearch) finds an endpoint by what you want done, with each one's price. - log treg get <id> (MCP tregGet) reads one endpoint in full: its price, its params and a call that works. - log treg call <id> --params '<json>' (MCP tregCall) calls it through the treg CLI on this machine, the key injected server-side by treg (TREG_BIN moves it). The price is read from get before every call. A free endpoint runs. A priced one is a dry run that says its price and the exact command, and runs only with --live and --approvedBy (MCP tregCallLive) when matt asked for that spend (rule 41). Every call is a timed span (rule 165), and treg's balance is in log balance (rule 164). It is the way every lookup reaches an external source (rule 53). src/extensions/treg, src/cli/treg, docs/extensions/treg.md. Spec: docs/specs/treg.md.

Rule 167. 2026-11-07 · matt: "we need to make it so that we're not needing to restart every time that we make a change ... it takes up at least 3 minutes of mine to close Claude Code and reopen it", then the options put to a SubQ matrix (issue #85; docs/decisions/mcpReload, own proxy 0.97 against reloaderoo, mcpmon, mcp-reloader and in-process re-import). logMcp is a small reload proxy that never exits (src/mcp/reload). It owns Claude Code's stdio pipe and runs the real server as a child (logMcp --child). When log's code stamp (codeStamp) is newer than the child's, it reloads, but only while no call is in flight and no job runs in the child (listJobs), so a queue run or a watch is never killed: - it starts a new child and replays the client's initialize; - once the child answers, it sends notifications/initialized, stops the old child and sends notifications/tools/list_changed up. A call caught by a crash is answered with logMcpRestarted so the client calls again. New code that doesn't start leaves the old child running and says why, and the next change tries again. Its state is ~/.logCli/mcp/reload.json (LOG_MCP_RELOAD_FILE). The stale-server hook line no longer tells matt to restart while the proxy runs. It says only what the reload is waiting on, or why the new code failed. A tool added since Claude Code started is reached at once through logCall (rule 163), since Windows clients have been seen ignoring list_changed. LOG_MCP_RELOAD=off runs the server without the proxy. Spec: docs/specs/mcpReload.md.