linkedin · diff
git:20260615.dd92619 to git:20260705.5a5efb9
27 added, 694 removed. Audit A to A.
---
name: linkedin
- description: "Use when browsing LinkedIn, reading posts, writing comments, checking DMs, or engaging with content. Triggered by requests to comment on LinkedIn, browse feed, interact with posts, or read/reply to LinkedIn messages."
+ description: "Use when reading posts or engaging on LinkedIn. Triggered by requests to comment on LinkedIn, browse the feed, write or interact with posts, check DMs, or read/reply to LinkedIn messages."
allowed-tools: mcp__byob__browser_list_tabs, mcp__byob__browser_navigate, mcp__byob__browser_read, mcp__byob__browser_get_html, mcp__byob__browser_click, mcp__byob__browser_type, mcp__byob__browser_press_key, mcp__byob__browser_scroll, mcp__byob__browser_wait_for, mcp__byob__browser_screenshot, mcp__byob__browser_close_tab, mcp__byob__browser_switch_tab, Bash(git:*), Read, Write, Edit, Grep, Glob, Agent
user-invocable: true
---
# LinkedIn Activity
- This skill drives the user's real, logged-in Chrome session via the **BYOB**
- stack — the Chrome extension + native messaging host + MCP server
+ Drives the user's real, logged-in Chrome session via the **BYOB** stack
(`mcp__byob__browser_*`). No CDP flag, no `state.json`, no
headless-fingerprint detection.
## Default Behavior (no arguments)
Run all three tasks in order:
1. **Check messages** — reply to any DMs that need attention
2. **Write a post** — about recent work from git history
3. **Browse feed** — find and comment on 3 posts
If arguments are given, interpret them and do only what's asked.
- **This skill executes — it does not pause for confirmation.** When the skill says "show the draft inline before publishing," that means render the draft in your response *and continue with the publish step in the same turn*. Don't stop to ask "should I post this?" — the user already opted in by invoking the skill, and they can interrupt mid-stream if they want changes. The only legitimate stop conditions are: (a) hard tool failure with no fallback (see the BYOB share-modal limitation under Task 2), (b) a finding that contradicts the skill's premise (e.g. "no DMs need replying" → skip Task 1 cleanly with one sentence of why).
+ **Nothing goes live — post, comment, or DM reply — without a PASS from `Skill('authenticity-pass')`; the full gate procedures are in `references/posting.md` and `references/feed-engagement.md`.**
+ **This skill executes — it does not pause for confirmation.** When the skill says "show the draft inline before publishing," that means render the draft in your response *and continue with the publish step in the same turn*. Don't stop to ask "should I post this?" — the user already opted in by invoking the skill, and they can interrupt mid-stream if they want changes. The only legitimate stop conditions are: (a) hard tool failure with no fallback (see the BYOB share-modal limitation in [references/posting.md](references/posting.md)), (b) a finding that contradicts the skill's premise (e.g. "no DMs need replying" → skip Task 1 cleanly with one sentence of why).
+
+ ## Task guides (load on demand)
+
+ Each task's full procedure lives in a reference file. Load only what the current run needs:
+
+ | Load... | When... |
+ |---|---|
+ | [references/dom-model.md](references/dom-model.md) | Before the first `browser_read`/`browser_click` on any feed, post, or profile page — the `byob:idx` workflow and hard-won gotchas |
+ | [references/messages.md](references/messages.md) | Task 1: reading the DM inbox, researching contacts, drafting and sending replies, updating the knowledge base |
+ | [references/posting.md](references/posting.md) | Task 2: extracting a portable lesson from recent work, subagent drafting, persona cold-read loop, authenticity gate, publish (and the BYOB share-modal limitation) |
+ | [references/feed-engagement.md](references/feed-engagement.md) | Task 3: volume targets, liking, following, comment screening, comment drafting loop, posting workflow — plus editing/deleting posted comments |
+
## Prerequisites
This skill needs **two preconditions** plus a **scheduler-gate flag**:
### 1. BYOB extension installed and connected
BYOB is the "Bring Your Own Browser" stack: a Chrome extension + native
messaging host + MCP server that lets this skill act on the user's already
logged-in Chrome (no headless profile, no per-session re-auth). Set up via
`/setup`'s computer-use opt-in or by following
[`docs/features/byob-browser-control.md`](../../../docs/features/byob-browser-control.md).
Verify the install in one shot:
```bash
cd ~/.byob && bun run doctor
```
All status lines should be green: extension loaded, native bridge running,
- Unix socket live. If any line is red, the BYOB MCP tools below will return
- a transport error -- run `/setup` and answer "yes" to the computer-use
- opt-in to repair.
+ Unix socket live. If any line is red, run `/setup` and answer "yes" to the
+ computer-use opt-in to repair.
- After `bun run doctor` passes, sanity-check from inside the agent that
- the extension is actually talking to Chrome by listing open tabs:
+ After `bun run doctor` passes, sanity-check that the extension is actually
+ talking to Chrome:
```text
mcp__byob__browser_list_tabs # returns the user's currently open Chrome tabs
```
- If `browser_list_tabs` returns an empty list or a transport error, the
- extension loaded but isn't bound to an active Chrome window -- open Chrome
- (or focus it) and retry. **Do not proceed to LinkedIn work until
- `browser_list_tabs` returns at least one tab.** A silent transport failure
- here means every subsequent BYOB call returns wrong-shaped output and the
- skill drives nothing.
+ **Do not proceed to LinkedIn work until `browser_list_tabs` returns at
+ least one tab.** An empty list or transport error means the extension isn't
+ bound to an active Chrome window — open or focus Chrome and retry. A silent
+ transport failure here means every subsequent BYOB call returns wrong-shaped
+ output and the skill drives nothing.
The user must be logged into LinkedIn in that Chrome session.
### 2. Real-Chrome scheduler gate (`requires_real_chrome=True`)
LinkedIn driving real Chrome must be **serialized** against any other
real-Chrome session — two concurrent BYOB sessions on the active tab
collide and corrupt each other's DOM. PR #1277 added the
`AgentSession.requires_real_chrome` field; the worker scheduler defers
any second real-Chrome candidate until the first finishes.
There are two paths that set the flag:
- **Bridge-spawned (Telegram or email)**: the bridge calls
`agent.byob_skill_triggers.infer_requires_real_chrome(message_text)`
before enqueue. Messages mentioning "linkedin" with first-person /
intent phrasing (e.g. "check my LinkedIn DMs", "/linkedin") match a
trigger and the flag is set automatically. No operator action required.
- **CLI-spawned**: launch with the explicit flag:
```bash
valor-session create \
--role eng \
--project-key valor \
--needs-real-chrome \
--message "list my linkedin DMs"
```
Always use `--needs-real-chrome` for any session that calls this skill.
Without it, two real-Chrome sessions can race.
## Tab discovery (do this first)
Always start by listing tabs and reusing an existing LinkedIn tab. Opening duplicates clutters the user's window and breaks `tabId`-targeted reads.
```text
browser_list_tabs → look for any tab whose URL contains "linkedin.com"
```
Pick the first match → that's your `tabId` for every subsequent navigate / get_html / click / type call. If no LinkedIn tab exists, call `browser_navigate(url="https://www.linkedin.com/feed/")` once to open one and use the returned `tabId`.
## The two-surface DOM model (critical)
LinkedIn has **two different DOMs** that need different read tools:
| Surface | DOM style | Read with | Click with |
|---|---|---|---|
| **Messaging** (`/messaging/...`) | Stable, named classes (`.msg-conversations-container__*`, `.msg-conversation-card__*`) | `browser_get_html(tabId, selector=".msg-*")` | `browser_click(tabId, selector="<stable .msg-* selector>")` |
| **Feed / Post / Profile** (`/feed/`, `/posts/...`, `/in/...`) | Hashed/obfuscated classes (`._06ad2747`); BYOB injects `data-byob-idx="N"` on every element | `browser_read(url, reuseTab=true, screens=5)` → use `interactiveElements` | `browser_click(tabId, selector="byob:idx=N")` where N is from the most recent read |
**Why this matters:** `browser_read` returns near-empty content on `/messaging/` (LinkedIn renders that surface in a way the read pipeline can't see). `browser_get_html` works there because it pulls real DOM via tabId. Conversely, on the feed the hashed classes are deploy-volatile — the only stable handle is the BYOB-injected `data-byob-idx`, which you address as `selector: "byob:idx=N"`.
- ### The `byob:idx` workflow
-
- 1. `browser_read(url, reuseTab=true, screens=5)` returns `interactiveElements: [{idx, tag, role, name, bounds}, ...]` — up to 1000 per call. The `name` is the accessible label ("Like", "Comment", "Sort by: Top", "Start a post").
- 2. Find the element you want by its `name` in that list.
- 3. Click it with `browser_click(tabId, selector="byob:idx=<that idx>")`.
- 4. **The next `browser_read` invalidates older indices** — its `interactiveSessionTag` changes. After every click that mutates the DOM (sending a comment, opening a thread, expanding a menu), re-read before the next click.
-
- When multiple `interactiveElements` map to the same logical control (the Like button shows up as `div role=button` + `p role=button` + `span role=button`), prefer the entry whose `tag: "button"` — that's the outermost real button. If none has `tag: "button"`, any of them clicks fine.
-
- ### Gotchas to remember
-
- - **`browser_navigate` accepts `tabId`** to reuse an existing tab. **`browser_read` does NOT accept `tabId`** — pass `reuseTab: true` along with the same URL the tab is already on. Without `reuseTab` you'll spawn a duplicate.
- - **`?sortBy=RECENT` is dropped** by LinkedIn on direct navigation. You land on the default Top feed. To switch, click the "Sort by: Top" element and pick "Recent" from the dropdown that opens (the dropdown lives in a portal that `browser_read` can't see — re-read after clicking and look for "Recent" in the new IE list, or just work the default Top feed).
- - **`browser_scroll` with `y: <number>` or `to: "bottom"` does nothing on the feed** — LinkedIn scrolls an inner container, not the window. The returned `scrollY` will be `0` regardless. Use `browser_scroll(tabId, text: "<unique substring>")` or `selector: "byob:idx=N"` to bring a specific element into view; ignore the `scrollY` field. For bulk feed loading, just bump `browser_read`'s `screens` parameter — it auto-scrolls and is the right tool.
- - **Feed reads cap at 1000 IEs.** When the read returns `stopReason: "limit_reached"` and `canContinue: true`, you've only seen the first slice. Process those, then call `browser_read` again to advance.
- - **Post bodies often don't appear in `interactiveElements`** because they're non-interactive `<div>`s. The IE list captures author headers, action buttons, and accessibility labels — not the post text itself. To read the actual post body, use `browser_get_html(tabId, selector="main")` and parse text out, or open the post URL directly (`/feed/update/urn:li:share:<id>/`) and use `browser_read` on the dedicated post page where the body usually surfaces in chunks.
- - **Tool-result file overflow:** both `browser_read` and `browser_get_html` on rich pages routinely exceed the inline tool-result limit and dump to `tool-results/*.txt`. Be ready to parse those out via Bash/grep/jq. Set realistic `maxBytes` and `screens` defaults to keep the inline path viable when you can.
- - **Block list:** BYOB upstream blocks reading `chrome://`, `file://`, and login pages for Google/Microsoft/Apple. Not relevant for in-session LinkedIn use.
-
- ---
-
- ## Task 1: Check Messages
-
- ### Voice for DMs
-
- Write as Valor Engels: direct, concise, genuine. DMs are conversational — not email.
-
- - **Short messages** — 1-3 sentences max unless depth is warranted
- - **Curious, not eager** — ask about their work, don't pitch ours
- - **No sycophancy** — no "so great to hear from you!", no performative enthusiasm
- - **No assumptions** — they might want to hire, collaborate, sell, or just say hi
- - **Warm but professional** — friendly peer, not a salesperson
-
- ### Read the inbox
-
- Navigate the existing tab, then pull the conversation list HTML directly (the messaging surface is stable-class territory):
-
- ```text
- browser_navigate(url="https://www.linkedin.com/messaging/", tabId=<linkedin_tab>, waitUntil="networkidle")
- browser_get_html(tabId=<linkedin_tab>, selector=".msg-conversations-container__conversations-list", maxBytes=32768)
- ```
-
- The HTML returned has one `<li class="...msg-conversation-listitem...">` per conversation. From each list item you can read:
-
- - `.msg-conversation-card__participant-names` → who it's with
- - `.msg-conversation-card__message-snippet` → preview text (often starts with `You:` if Valor was last sender)
- - `.msg-conversation-card__pill` → badges like "Sponsored" (skip these)
- - `.msg-conversation-listitem__time-stamp` → recency
-
- **Default skip rules** (apply before opening any conversation):
- - Sponsored ads → skip
- - Snippet starts with `You:` AND the timestamp is < 4 weeks old → skip (you're already waiting on them; following up reads as needy)
- - Obvious recruiter templates → skip
- - If after these filters the inbox is empty: state "no DMs need replies right now" with a one-line reason and move to Task 2. Don't open conversations just to confirm.
-
- For each remaining conversation worth attention, open it:
-
- ```text
- browser_click(tabId=<linkedin_tab>, selector="li.msg-conversation-listitem:nth-of-type(<N>) .msg-conversation-listitem__link")
- browser_wait_for(tabId=<linkedin_tab>, selector=".msg-s-message-list-content", state="visible", timeoutSec=5)
- browser_get_html(tabId=<linkedin_tab>, selector=".msg-s-message-list-content", maxBytes=8192)
- ```
-
- Understand: who is this person, what did they say, is this new/ongoing/cold outreach? Spam and recruiter templates don't need replies.
-
- ### Research before replying (mandatory)
-
- ```bash
- ls ~/work-vault/Consulting/leads/
- ls ~/work-vault/Consulting/chats/
- ```
-
- - **Known lead** (in `leads/`): Read their file. Reply with awareness of what they need.
- - **Known chat** (in `chats/`): Read their file. Reply conversationally.
- - **Unknown person**: Quick profile scan. Default to friendly and curious.
- - **Spam/automated**: Skip.
-
- ### Draft and send
-
- Write draft to `/tmp/linkedin-reply.txt`.
-
- **Message style by context:**
- - **Greeting**: Respond warmly, ask what they're working on
- - **Question about our work**: Answer directly, link to code/docs if relevant
- - **Business inquiry**: Ask what they're trying to solve — don't pitch
- - **Cold outreach/sales**: Polite one-sentence decline or redirect
-
- Quality check: Is it short enough? Does it invite a response without being needy? Would Valor actually say this?
-
- Then send it. The message input is a contenteditable, not an `<input>`:
-
- ```text
- browser_click(tabId=<linkedin_tab>, selector=".msg-form__contenteditable")
- browser_type(tabId=<linkedin_tab>, selector=".msg-form__contenteditable", text="<reply text>", clear=true)
- browser_click(tabId=<linkedin_tab>, selector=".msg-form__send-button")
- browser_wait_for(tabId=<linkedin_tab>, selector=".msg-form__contenteditable[aria-label*='empty']", state="visible", timeoutSec=5)
- ```
-
- If a selector ever returns `selector_not_found`, dump a fresh `browser_get_html(selector=".msg-form")` and read what the current class names are — the stable thing here is the `msg-form__` prefix, not specific suffixes.
-
- ### Update knowledge base
-
- - **Confirmed lead**: Update `~/work-vault/Consulting/leads/{name}.md`
- - **New professional contact**: Create `~/work-vault/Consulting/chats/{name}.md`
- - **Casual/one-off**: No file needed
-
- ---
-
- ## Task 2: Write a Post
-
- ### Audience model
-
- LinkedIn is broad: PMs, designers, executives, salespeople, students, recruiters. Most readers will never see the codebase, and most aren't engineers. **Write for the smart professional in a different field, not the peer who builds the same thing.** A successful post lets a marketer, lawyer, or product manager close the tab feeling they learned something useful for their own work.
-
- If the only audience that can decode the post is "engineers who build the same thing I do," it's a blog post, not LinkedIn. Save it for the repo's docs.
-
- ### Research
-
- ```bash
- git log --oneline --since="5 days ago"
- ```
-
- Read the relevant files and feature docs. But don't stop at "what changed" — keep going until you can answer:
-
- **What general lesson did this work teach me, that anyone could use?**
-
- That lesson is the post. The codebase work is one concrete example of the lesson, not the subject of the post.
-
- **Only post if there's a portable lesson.** Skip routine bug fixes, formatting, dependency bumps, and any insight whose only audience is engineers in the same niche.
-
- ### Draft (delegate to a fresh subagent)
-
- **Drafting happens in a subagent, not the parent session.** The parent session is loaded with engineering context — code excerpts, file paths, internal jargon — and that context bleeds into any post written inline. The post turns into an internal memo. A subagent starts cold and only knows what its brief tells it.
-
- The parent's job here is to assemble a brief and hand it off. Keep the candidate technical work in your hands; pass only what the subagent needs to extract the lesson.
-
- **Spawn the subagent with `general-purpose`** and the prompt template below. Fill in the source material; everything else is fixed.
-
- ```
- You are drafting a LinkedIn post on behalf of Valor Engels (software engineer
- at Yudame). The audience is the broad LinkedIn feed: PMs, designers,
- executives, salespeople, students, recruiters. Most readers will never see
- the codebase, and most aren't engineers.
-
- THE NON-NEGOTIABLE RULE: write so a marketing director, a lawyer, or a
- product manager — someone smart with no engineering background — finishes
- the post and feels they learned something useful for their own work. If
- only engineers in the same niche can decode it, you've failed the brief.
-
- ## Source material
-
- <<<
- [Paste here: relevant commit hashes, the feature doc path(s), and a 2-3
- sentence factual summary of what was built and why. Nothing more. Do NOT
- paraphrase the technical detail in the brief — let the subagent read the
- files and extract the lesson itself.]
- >>>
-
- Read those files yourself before drafting.
-
- ## The hard step: extract the portable lesson
-
- Before drafting, write down explicitly: what general lesson did this work
- teach, that anyone could use?
-
- The lesson must be portable. It should make sense outside this codebase,
- outside engineering, ideally outside tech entirely. If your candidate
- lesson is something like "deterministic call sites should be cached," try
- again — that's a tactic, not a lesson. Keep distilling until you have a
- sentence like "Before optimizing for speed, audit what you're assuming is
- holding still" — something a contracts team reusing past clauses or a
- designer reusing past templates would also nod at.
-
- The lesson IS the post. The codebase work is one concrete instance of the
- lesson, not the subject.
-
- ## Structure
-
- 1. **Lead with the lesson, never with the setup.** Two legal opener
- patterns:
- - *Lesson-hook*: sentence one IS the takeaway, in plain language.
- - *Promise-hook*: sentence one teases that the rest is worth your
- time ("Here's something we keep relearning the hard way:").
- What fails is starting with three setup sentences before the payoff.
- Most LinkedIn readers bounce in two sentences. Get them to the
- lesson first.
- 2. Set the stage. One or two sentences naming the kind of situation where
- this lesson shows up. Use everyday framing.
- 3. One concrete example. Drawn from the codebase, stripped to the smallest
- amount of jargon needed to make the point. If you must use a term like
- "cache" or "model," explain it in passing or replace with a plain
- analogy. Specifics earn their place by making the lesson vivid; they do
- not carry the post.
- 4. Land on a portable takeaway. A closing sentence the reader can apply to
- their own field. Must make sense to someone who never reads this
- codebase.
-
- ## Genericize incidental specifics, keep load-bearing ones sharp
-
- The post's anchor (the actual lesson, the actual fix, the named pattern
- the post is built around) MUST stay specific. But scaffolding details
- that aren't the lesson should be genericized so the post travels beyond
- people running the same exact stack. Naming "Redis" in a post about
- shared state makes a Postgres user think it doesn't apply when it does.
- "Anything sharing state" travels; "a Redis instance" narrows.
-
- Rule of thumb: for each named third-party tool / vendor / specific tech
- in the draft, ask — would the lesson still land if I swapped this for
- a different example? If yes, the named thing is incidental scaffolding,
- not the anchor. Replace with the generic category ("any cache," "a
- database," "anything sharing state"). Keep load-bearing specifics (the
- actual tool the lesson is about, the actual fix). Strip incidentals.
-
- ## Style
-
- - Write like a teacher to a curious adult learner. Generous, plainspoken,
- specific.
- - No abbreviations or in-jargon without translation. Terms like `os.replace`,
- `asyncio.Lock`, `MODEL_EXPERIMENT`, `sha256`, `LRU`, `RAG`, `MCP`,
- `Popoto`, `pull request`, `pytest`, `CI`, file paths, function names —
- none of those belong in a LinkedIn post. ("Pull request" in particular:
- most non-engineers read it as "asking for something" — translate to
- "a proposed change" or just "a change.") If you find yourself reaching
- for any of these, the lesson hasn't been translated yet.
- - No listicle bullets unless the content is naturally a list.
- - No "we just shipped" / "I just built" framing — that's an announcement.
- - No performative humility, no chest-thumping.
-
- ## Length and closing
-
- - ~800 characters as default. Go longer only if the lesson needs the room.
- - End with `github.com/tomcounsell/ai` and 3-5 hashtags from this set:
- #AIAgents #AgenticAI #ClaudeAI #OpenSource #DeveloperTools #LLMs
- #MachineLearning #SoftwareEngineering
-
- ## Mandatory self-review before returning
-
- 1. Casual-reader test. Read your draft pretending you're a marketing
- director who has never written code. Do you finish with something
- useful for your own work? Do you bounce off jargon by sentence two?
- If the post only makes sense to an AI engineer, the lesson hasn't been
- extracted yet. Start over from the lesson step.
- 2. Lead test. Is the lesson in sentence one (or sentence one teases that
- the lesson is coming)? If sentence one is project context or setup,
- rewrite so the point is first.
- 3. Substance. Cut anything that reads like an announcement or a feature
- changelog. The lesson is at the front; the example earns its place by
- making the lesson vivid.
- 4. Em-dash scan. Search the draft for "—". If you find ANY, replace with
- periods, colons, commas, or parentheses. Em-dashes are a vanilla-LLM
- tell in 2026 and readers discount the post on sight. Zero tolerance.
- 5. Genericize check. For each named third-party / vendor / specific tech
- in the draft, ask: would the lesson still land if I swapped this for
- a different example? If yes, the named thing is incidental
- scaffolding. Replace with the generic category. Keep load-bearing
- specifics (the actual tool the post is about, the actual fix). Strip
- incidentals.
-
- ## Output
-
- Write the final draft to `/tmp/linkedin-post.txt` and return the full
- draft text in your reply, plus one line stating the portable lesson you
- extracted.
- ```
-
- ### Iterate: 3-4 rounds of draft + cold-read with rotating personas
-
- Single-shot drafts ship mediocre posts. The first draft buries the lesson
- in setup, the second tightens the opener, the third lands the takeaway.
- Build that loop into the workflow.
-
- A *single* cold-reader spirals into agreement with the drafter after one
- round. They both end up grading drafts on whether they match the prior
- round's critique rather than whether they actually land on a stranger.
- **Fix: rotate distinct personas across rounds. Fresh subagent each time
- (no shared context).** Where two of four personas flag the same problem,
- the problem is real and the fix is mandatory.
-
- The four personas (each a fresh `general-purpose` subagent):
-
- 1. **The Casual Professional Reader** — a marketing director, lawyer, or
- PM with no engineering background. Bias: posts that require
- engineering jargon to decode are filler for them. Bar: do they finish
- feeling they learned something useful for *their* work?
- 2. **The LLM-Tell Hunter** — hunts for vanilla-LLM register: em-dashes,
- tricolons, "in essence," "fundamentally," "it's not just X, it's Y,"
- "this isn't about A, it's about B," over-symmetric sentences,
- listicle smell. Grade is binary: tells present (D or worse) or
- absent (A possible).
- 3. **The Generalist Engineer** — works in tech but a different niche
- (mobile, data, embedded). Catches inscrutable in-niche jargon and
- the "even pros need three reads" failure mode.
- 4. **The Skeptic** — assumes the post is overstating. Hunts for the
- unsupported claim, the "in our experience" that's one anecdote,
- the tradeoff treated as a free lunch.
-
- **Round assignment:**
- - Round 1 cold-read: Casual Professional Reader (catches jargon and
- buried-lead failures first; this is the post's actual audience)
- - Round 2 cold-read: LLM-Tell Hunter (strips register tells before
- deeper iteration)
- - Round 3 cold-read: Generalist Engineer
- - Round 4 cold-read (if needed): Skeptic
-
- **Cold-read prompt template** (parameterize `{PERSONA}` and
- `{PERSONA_BIAS}` per round):
-
- ```
- You are {PERSONA}. {PERSONA_BIAS}
-
- You have NO knowledge of the author, no knowledge of any specific
- codebase, and no insider context. You see this draft LinkedIn post:
-
- ---
- [paste current draft]
- ---
-
- Grade it strictly through your specific lens. The bar for B+ is: a
- stranger reads to the end and finishes with something useful for their
- own work. The bar for A is: a stranger considers reposting or sharing.
-
- Sympathy is the enemy. Do NOT grade on a curve. If your specific bias
- finds a problem, that grade is the grade, even if other aspects are
- fine.
-
- Answer in this exact structure:
-
- GRADE: [A / A- / B+ / B / B- / C / D / F]
- WOULD YOU READ TO THE END: [yes / no / maybe + one sentence why]
- WHAT THE POST IS SAYING: [one-sentence paraphrase in plain English]
- WHAT'S BURIED: [where the lesson actually lives if not in sentence one]
- {PERSONA}-SPECIFIC FINDING: [the one thing your bias catches best]
- TOP THREE FIXES: 1. ... 2. ... 3. ...
-
- Total under 250 words. Be blunt.
- ```
-
- **Persona briefs** (substitute into the template):
-
- - **Casual Professional Reader** — *"You are a marketing director (or
- lawyer, or PM). You've never written code. You read LinkedIn on the
- train. Your bias: any post that needs engineering jargon to follow is
- not for you. Decide: did you finish with something useful for your
- own work?"*
- - **LLM-Tell Hunter** — *"You hunt for vanilla-LLM register: em-dashes,
- tricolons, 'in essence,' 'fundamentally,' 'it's not just X, it's Y,'
- over-symmetric sentences, listicle smell. Your grade is binary: tells
- present (D or worse) or absent (A possible)."*
- - **Generalist Engineer** — *"You work in tech but not this niche.
- You're smart but uninitiated. Every term that requires a Wikipedia
- tab is a strike. If a working pro in a different specialty can't grok
- it on first read, it failed."*
- - **Skeptic** — *"You assume the post is overstating. You hunt for the
- unsupported claim, the 'in our experience' that's one anecdote, the
- tradeoff treated as a free lunch. Posts without defensible specifics
- are vibes."*
-
- **The loop:**
-
- 1. Drafter subagent produces v1 to `/tmp/linkedin-post.txt`.
- 2. Cold-read v1 with **Casual Professional Reader** (fresh subagent).
- 3. Drafter rewrites v2 from the critique. Don't merge inline. Let the
- drafter rewrite from scratch with the critique as input. Inline
- patches accumulate into Frankenstein drafts.
- 4. Cold-read v2 with **LLM-Tell Hunter** (fresh subagent).
- 5. Draft v3 → cold-read with **Generalist Engineer**.
- 6. Round 4 only if any prior round graded below B+: draft v4 →
- cold-read with **Skeptic**.
- 7. **Ship the highest-graded version.** If no version reaches B+, drop
- the post. The premise is the problem, not the prose. Better to ship
- nothing than a D-tier post.
-
- When the subagent returns the final draft, the parent session:
- 1. Reads `/tmp/linkedin-post.txt`
- 2. **Invokes `Skill('authenticity-pass')`** — PASS proceeds; BLOCK returns the draft to the drafter with the blocking gaps as revision instructions (max 2 retries before dropping the post)
- 3. Renders the final post inline in the response
- 4. **In the same turn**, attempts to publish (see ⚠️ below)
-
- ### Publish
-
- > **⚠️ KNOWN LIMITATION (verified live 2026-05-05, see PR #1286 / issue #1274):** BYOB **cannot drive LinkedIn's "Start a post" composer modal.** Clicking the "Start a post" trigger opens the modal visually (confirmed via screenshot showing "What do you want to talk about?"), but the modal's contenteditable textbox renders inside a **React portal** that `browser_read`, `browser_get_html`, and `browser_wait_for` cannot traverse. With no selector to target, `browser_type` has nothing to type into. `browser_press_key` does dispatch into the focused textbox, but it's single-key-per-call — typing 1500 chars one at a time is impractical (1500 round-trips). This is a general BYOB gap (every React-portal-rendered modal on every site likely has the same problem), not a LinkedIn-specific one — worth filing upstream.
- >
- > **What to do until BYOB resolves this:** at the point you'd publish, render the final draft inline AND state plainly: *"BYOB can't drive the post composer modal yet (React portal — see SKILL.md KNOWN LIMITATION) — paste this from `/tmp/linkedin-post.txt` into LinkedIn yourself, or send via the mobile app where the share flow is different."* Then proceed to Task 3 (which works fine — comments use an inline textbox, not a portal modal).
- >
- > Things that have been verified NOT to work for the share modal:
- > - `browser_click("byob:idx=...")` on every "Start a post" IE variant (`idx=137..141`, `tag` "div"/"p", with and without `force:true`) — modal opens but textbox not in document
- > - `browser_get_html("body")` after open (returns navigation chrome only — modal lives elsewhere)
- > - `browser_get_html("[contenteditable=true]")`, `[role='dialog']`, `.share-creation-state`, `.ql-editor` (all `selector_not_found`)
- > - `browser_wait_for(...)` for any of those selectors (always times out)
- > - Hashed React class selectors are deploy-volatile and not worth chasing
- >
- > If/when BYOB gains portal traversal, the intended flow is: navigate → read → click "Start a post" idx → re-read for editor textbox idx → `browser_type` the post text → re-read for "Post" submit idx → click → screenshot for confirmation.
-
- ---
-
- ## Task 3: Browse Feed and Comment
-
- ### Volume targets
-
- The LinkedIn feed algorithm sharpens to who you like, comment on, and
- follow. Sparse engagement keeps the feed thin. Targets per run:
-
- - **Like 15-40 posts.** Anything genuinely relevant (agentic systems,
- memory, async pipelines, LLM tooling, dev tools, AI in production,
- builder posts from people doing real work, plus adjacent design /
- product / strategy posts that read as substantive). Likes are cheap
- signal. Be liberal but not indiscriminate.
- - **Comment on up to 5.** Apply the screening gate below. Quality still
- matters more than volume. 2-5 is the normal range; fewer is fine if
- the feed is thin.
- - **Follow 3-10 new people you haven't connected with.** This is the
- main feed-curation lever (see "Following new people" below).
-
- Infinite scroll means there's always more to read. Don't stop after one
- screen if the feed is yielding signal.
-
- ### Read the feed
-
- ```text
- browser_navigate(url="https://www.linkedin.com/feed/", tabId=<linkedin_tab>, waitUntil="networkidle")
- browser_read(url="https://www.linkedin.com/feed/", reuseTab=true, screens=5)
- ```
-
- `screens=5` lets BYOB auto-scroll five viewport heights to load lazy posts. Bump to 10+ if you need more. If the read returns `stopReason: "limit_reached"`, call `browser_read` again to get the next slice (the IE indices reset; `interactiveSessionTag` changes).
-
- ### React as you scroll
-
- As you read through posts, Like any that are relevant to the work — agentic systems, memory, RAG, async architecture, developer tooling, AI in production. This trains the feed toward more of the same. A thumbs-up takes one click and doesn't require a comment.
-
- **Commenting always implies a Like.** If a post passes the screening gate and you draft a comment for it, also like the post. Engagement should be coherent — a comment without the like reads as half-engaged. The reverse isn't true: plenty of posts are worth a like but not a comment.
-
- In the IE list, Like buttons appear with `name: "Reaction button state: no reactionLike"` (or `name: "Like"`). After clicking, the same button's name flips to `"Reaction button state: Like"` — that's the reliable success indicator (don't gate on reaction-count diffs, those can lag). If the name already shows `"Reaction button state: Like"` or `"Unreact Like"`, the post is already liked — skip.
-
- ```text
- browser_click(tabId=<linkedin_tab>, selector="byob:idx=<like_idx>")
- ```
-
- ### Following new people (feed curation lever)
-
- The LinkedIn feed tunes to who you follow and engage with. To shape the
- feed toward higher-signal builders / strategists / researchers:
-
- 1. **Scan the feed for authors you haven't followed yet.** Author names
- surface in `<article>` IE `name` fields and in post permalinks.
- 2. **Screen the profile before following.** Open `https://www.linkedin.com/in/{handle}/`
- and skim recent posts:
- - Are they posting concrete observations / shipping things, or just
- hot takes and reposts?
- - Do their posts read like the patterns under "What works on this
- feed"? (sharp behavioral observation, specific numbers, real
- examples, builder-noticing-builder)
- - Avoid pure-influencer accounts (engagement-bait questions, listicle
- hot-takes that just summarize others' work, "DM me for the
- framework" patterns).
- 3. **Follow if yes.** Follow buttons on LinkedIn vary by profile state.
- Use the IE list to find the button named "Follow" (not "Connect" or
- "Message"). Already-following accounts show "Following".
-
- Target 3-10 follows per run.
-
- ### Screen candidates (mandatory before drafting)
-
- For each candidate, grep the codebase for the post's core topic:
-
- ```
- Check: tools/, bridge/, agent/, docs/features/, config/, .claude/skills/
- ```
-
- **Before checking the codebase, classify the post cold:** Ignore the browsing session so far. Ask — what domain is this post actually in? Who is the intended audience? What would a relevant reply look like for *them*? Write that classification in one sentence before proceeding.
-
- Then ask: does this codebase operate in that domain?
-
- A post passes if:
- - The post's domain is one we work in directly (agentic systems, memory, async pipelines, LLM tooling, etc.)
- - There's a specific file, pattern, or decision in this codebase that speaks to the post's actual claim
- - The comment would make sense to the post's audience without any context about Claude or AI agents
-
- A loose analogy ("this reminds me of a different problem we had") is not a pass. Keyword overlap without domain overlap is not a pass.
-
- Target: 3 posts that pass this gate. If the feed only yields 1-2 quality candidates, post fewer good comments rather than padding with weak ones.
-
- ### Read the full post body before drafting
-
- The feed inline rarely shows the full post body in `browser_read`'s text or IE list. Always open the post URL directly before drafting:
-
- ```text
- browser_navigate(url="https://www.linkedin.com/feed/update/urn:li:share:<id>/", tabId=<linkedin_tab>, waitUntil="networkidle")
- ```
-
- Then either:
- - `browser_read(url=..., reuseTab=true, screens=2)` — works on the dedicated post page (text usually surfaces in chunks), OR
- - `browser_get_html(tabId, selector="main")` and grep visible text out
-
- **Why this matters:** A live test once drafted a comment from the feed snippet alone and missed the post's actual punchline (the joke was buried below the visible fold). The author was making a satire about ChatGPT's sycophant tic; the draft had earnestly engaged with a parenthetical they'd thrown in. Always read the full body before extracting the audience and the insight.
-
- ### Audience model for comments
-
- **The post sets the audience, not your codebase.** A strategy post's readers are PMs and execs — write for them. A design-system post's readers are designers — write for them. Only when an AI-engineering post is in front of a self-selected technical audience can you reach for technical framing, and even then with restraint.
-
- Common failure mode: the parent session has just spent 20 minutes inside engineering files and writes a comment that mirrors that context — file paths, function names, internal terms — onto a post whose audience can't decode any of it. The codebase grounding is for *you*, not for the comment text. File paths and function names belong in the drafter's notes, never in the published reply.
-
- ### For each post: read, gather, delegate, verify
-
- **Gather (parent session):** Inside the parent, do the codebase research and extract:
- - The post's audience (one sentence — strategist? designer? AI engineer?)
- - The portable insight you'd bring (one sentence in plain language — no jargon, no file paths)
- - Two or three lines of plain-language experience that ground the insight (what we've seen, in everyday terms — "we run a system that decides which AI requests to skip when the inputs haven't changed" beats "JSON cache for deterministic call sites")
-
- Pass these to a fresh subagent. The subagent does NOT see the codebase. It only sees what you tell it.
-
- **Delegate to subagent (`general-purpose`)** with this prompt template:
-
- ```
- You are drafting a LinkedIn comment on behalf of Valor Engels (software
- engineer at Yudame). The audience is set by the original post — not by
- Valor's codebase. Your job is to write a comment the post's audience will
- find genuinely useful.
-
- ## The post
-
- <<<
- [Paste the full post text here.]
- >>>
-
- ## The post's audience
-
- <<<
- [One sentence: who reads this post? Strategists? Designers? AI engineers?
- Recruiters?]
- >>>
-
- ## What to bring
-
- Portable insight (the parent already extracted this for you):
- <<<
- [One sentence in plain language. The parent did the engineering research;
- you just need to say it well for the audience.]
- >>>
-
- Plain-language experience that grounds it:
- <<<
- [2-3 lines, jargon-free. What we've seen. No file paths, no function
- names, no internal terms.]
- >>>
-
- ## How to write the comment
-
- 1. Lead with the portable insight, in language the post's audience uses.
- Comments are read top-down with attention falling fast — the insight
- goes in sentence one.
- 2. Add the experience as supporting evidence, in 1-2 sentences.
- 3. Optionally close with a question or sharpened line that invites
- thought, not a sign-off.
-
- ## Hard rules
-
- - Match the post's audience, not Valor's codebase. If the post is in plain
- English, the comment is in plain English.
- - No file paths (`config/models.py`, `MODEL_EXPERIMENT`), no function
- names, no internal terms (`Popoto`, `MCP`, `RAG`, `LLM call sites`),
- no Python identifiers. These are bragging tokens, not contributions.
- If a term you reach for would not appear in the post itself, don't put
- it in the comment.
- - No sycophantic opener ("Great point!", "Love this!").
- - No fake authority. The grounding is the experience the parent gave
- you, not invented detail.
- - ZERO em-dashes (—). They are a vanilla-LLM tell in 2026. Use periods,
- colons, commas, or parentheses instead. Search-and-destroy before
- output.
-
- ## Length
-
- Default ~200-300 characters. Go up to ~600 only if the insight genuinely
- needs the room. Hard cap 1250 (LinkedIn limit).
-
- ## Self-review before returning
-
- 1. Audience check. Read your draft as if you were the post's audience
- (a strategist for a strategy post, a designer for a design post). Does
- it land? Or does sentence one use a term they'd skim past?
- 2. Bragging-token check. Search your draft for any file path, function
- name, or internal-jargon term. If present, rewrite without them.
- 3. Insight-up-front check. Is the portable insight in sentence one? If
- it's the closing line, move it.
- 4. Em-dash scan. Search the draft for "—". If you find ANY, replace
- with periods, colons, commas, or parentheses. Zero tolerance.
-
- ## Output
-
- Return the final comment text. Nothing else.
- ```
-
- **Iterate: 2 rounds of draft + cold-read with rotating personas.**
- Comments are lower-stakes than posts, so 2 rounds usually beat 4. Use
- TWO different personas across the rounds, never the same one twice, to
- avoid echo-chamber convergence.
-
- - **Round 1: LLM-Tell Hunter.** Strips obvious AI register early so
- round 2 isn't masked by it.
- - **Round 2: Audience Stand-In.** A fresh subagent role-playing the
- post's actual audience (strategist for a strategy post, designer for
- a design post, etc.). Asks "would this comment land for *me*?"
-
- Cold-read prompt for comments:
-
- ```
- You are {PERSONA}. {PERSONA_BIAS}
-
- Original post: [paste]
- Comment candidate: [paste]
-
- Grade strictly through your lens:
- - A: genuinely sharpens, corrects, or extends the original.
- - B+: adds something a reader of the original wouldn't have thought of.
- - C: restates the original in new words.
- - D: sycophantic, generic, or wrong audience.
-
- GRADE: ...
- WHAT IT ADDS (or fails to add): one sentence
- {PERSONA}-SPECIFIC FINDING: ...
- TOP TWO FIXES: 1. ... 2. ...
-
- Total under 150 words. Be blunt.
- ```
-
- After v2: ship if B+ or better. If still C-D, the comment premise is
- wrong. Drop it and pick a different post to engage.
-
- **Verify (parent session):**
- 1. Read the final draft against audience-check, bragging-token, and
- insight-up-front rules one more time
- 2. Render the final comment inline in your response
- 3. **In the same turn**, save to `/tmp/linkedin-comment-N.txt` and post
- via BYOB
-
- ### Posting workflow (verified live)
-
- ```text
- # Already on the post page from the read step. Like first (lower stakes).
- browser_read(url="<post_url>", reuseTab=true, screens=2)
- # Find the post-action-bar Like — name "Reaction button state: no reaction" with tag "button"
- browser_click(tabId=<linkedin_tab>, selector="byob:idx=<like_idx>")
-
- # Type the comment into the inline textbox — name "Text editor for creating comment", role "textbox"
- browser_type(tabId=<linkedin_tab>, selector="byob:idx=<editor_idx>", text="<comment text>")
-
- # Re-read — typing into the textbox enables a NEW "Comment" submit button (button tag, distinct from the post-action-bar "Comment" that opens the composer)
- browser_read(url="<post_url>", reuseTab=true, screens=1)
- # The submit button is named "Comment" (button tag) and sits to the right of the textbox (high x-coordinate, e.g. ~844px); the post-action-bar "Comment" sits at ~565px and just opens the composer
- browser_click(tabId=<linkedin_tab>, selector="byob:idx=<submit_idx>")
-
- # Confirm — screenshot OR re-read and look for "Reaction button state: Like" on the post-level button + comment count incremented + your comment appearing with author "Valor Engels" and timestamp "now"
- browser_screenshot(tabId=<linkedin_tab>, savePath="/tmp/linkedin-comment-confirmation.jpg", format="jpeg", quality=55)
- ```
-
- **Distinguishing the two "Comment" IEs**: after typing, you'll see TWO entries with `name: "Comment"`:
- - The post-action-bar Comment (lower idx, bounds x ≈ 565) — this OPENS the composer; clicking does nothing useful when composer is already open
- - The submit Comment (higher idx, bounds x ≈ 844) — this PUBLISHES the comment
-
- Pick the higher-x-coordinate one. After successful submit the textbox empties and your comment appears in the comment list with timestamp "now".
-
- ---
-
- ## Editing Comments
-
- **Never use Edit to post a correction.** Editing replaces the full text — a "Correction: ..." opener reads as nonsense once the original is gone.
-
- If a posted comment needs correcting:
- - **Delete it** and post a clean replacement, or
- - **Post a new reply** beneath it with the correction in context
-
- Only use Edit to fix typos or rewrite the whole comment as a clean standalone.
-
- ---
+ The step-by-step `byob:idx` workflow and the gotcha list (scroll behavior, `reuseTab`, IE caps, post-body reads, tool-result overflow) live in [references/dom-model.md](references/dom-model.md) — read it before the first feed/post/profile interaction.
## Notes
- **Always reuse the same `tabId`** across a session. Discover it once at the start and pass it to every call.
- - **Re-read after every DOM-mutating click** — sending a comment, opening a thread, expanding a menu, opening the post composer. The `interactiveSessionTag` changes; old `byob:idx` values no longer point where you think.
- - **`browser_get_html` for messaging, `browser_read` for feed.** They're not interchangeable on LinkedIn.
- - **`scrollY` is meaningless on LinkedIn** — it's an inner scroll container. Don't gate decisions on the value `browser_scroll` returns.
- **Wait 2-3s after navigation** for SPA hydration — `waitUntil="networkidle"` mostly handles this; add `browser_wait_for(selector, state="visible")` for specific elements.
- **Opening a message marks it as read** — be aware of "seen" indicators if you don't intend to actually engage.
- **Screenshots over 1MB fail** — use `format="jpeg"` and `quality=50-60` for confirmation captures.
- - **No fallback browser surface.** BYOB is the only browser tool. If the bridge dies mid-session, the agent surfaces a clear error — run `cd ~/.byob && bun run doctor` to repair, then retry.
- - **If `mcp__byob__browser_*` calls return transport errors mid-session**, the Chrome extension may have lost its bridge — run `cd ~/.byob && bun run doctor` in a fresh shell to repair, then retry the failed call.
+ - **No fallback browser surface.** BYOB is the only browser tool. If `mcp__byob__browser_*` calls return transport errors mid-session, run `cd ~/.byob && bun run doctor` in a fresh shell to repair, then retry the failed call.