Claudilon: an AI that replies to my LinkedIn comments in real time

I published a post on LinkedIn about Go concurrency. Comments started arriving. I was in a meeting. By the time I got back, the conversation had moved on — unanswered questions, a thread that had gone cold. I thought: what if a bot could handle this while I'm away?

That's Claudilon. A Playwright script that monitors my LinkedIn posts, detects new comments, feeds them to Claude CLI, and posts the reply — all within 30 seconds. No LinkedIn API. No webhook. No Zapier. Just a headless browser and a shell command.

Why there's no LinkedIn API for this

The LinkedIn API exists. It's also essentially unusable for personal automation. To get write access — which you need to post comments — you must submit an app for Partner Program review. LinkedIn reviews it manually. You need a verified company, a use case that matches one of their approved marketing categories, and months of wait time.

The Marketing Developer Platform, which is the only route to comment-related APIs, is explicitly designed for enterprise CRM integrations and social media schedulers — not for a developer who wants to keep his own conversations going. The free API tier doesn't include comment reading or writing at all.

So: scraping. Not ideal, but the only realistic option that doesn't require corporate paperwork. LinkedIn's rate limits and bot detection are real, but manageable if you're only monitoring one account and not hammering the DOM every second.

Architecture: Playwright + Node.js + Claude CLI

The stack is deliberately minimal:

  • Playwright (Node.js) — browser automation, authenticated session via stored cookies
  • Claude CLI — one-shot invocation per comment, no API key management in the script
  • A JSON file — tracks already-replied comments, the anti-loop mechanism
  • A systemd user service — persistent daemon, auto-restart, clean shutdown

The script runs in a loop, processes new comments, writes to state files, sleeps 30 seconds, repeats. Systemd handles process supervision. That's it — no message queue, no orchestration layer.

scripts/
├── linkedin-auto-reply.js         # Main script
├── .linkedin-cookies.json         # LinkedIn session (gitignored)
├── .linkedin-comments-seen.json   # Replied comment URNs (gitignored)
└── .linkedin-notif-seen.json      # Processed notification IDs (gitignored)

~/.config/systemd/user/
└── claudilon.service              # Systemd unit

Hybrid mode — notifications page + watched posts

Scraping each watched post individually on every cycle means N page loads per cycle. LinkedIn notices patterns like that. The fix is a single notifications page.

LinkedIn has a notifications page filterable to "My posts" — one page load that surfaces all recent comments across all your publications. The bot loads this once per cycle, extracts new comment URNs, and only navigates to individual posts when it needs thread context. WATCHED_POSTS still exists for high-priority posts that need guaranteed coverage, but the notifications page is the primary source.

const WATCHED_POSTS = [
    { urn: 'urn:li:share:7301234567890123456', slug: 'goroutine-leaks-golang' },
    { urn: 'urn:li:share:7309876543210987654', slug: 'cqrs-go-postgresql-event-store' },
];

async function runCycle() {
    // 1 page load covers all posts
    const fromNotifs = await scrapeNotifications(page);

    // Direct scrape for priority posts
    const fromWatched = [];
    for (const post of WATCHED_POSTS) {
        const comments = await scrapePostComments(page, post.urn);
        fromWatched.push(...comments.map(c => ({ ...c, slug: post.slug })));
    }

    // Merge + deduplicate on comment URN
    const all = mergeByUrn(fromNotifs, fromWatched);
    return all.filter(c => !seenComments.has(c.urn));
}

Scraping: authenticated session and comment extraction

The first step was capturing an authenticated LinkedIn session. Playwright makes this straightforward — log in manually once, save the browser storage state, reuse it on every subsequent run.

// One-time setup: save session after manual login
const { chromium } = require('playwright');

(async () => {
    const browser = await chromium.launch({ headless: false });
    const context = await browser.newContext();
    const page = await context.newPage();

    await page.goto('https://www.linkedin.com/login');
    // Manual login — wait until redirected to feed
    await page.waitForURL('**/feed/**', { timeout: 120_000 });

    await context.storageState({ path: 'cookies.json' });
    await browser.close();
    console.log('Session saved.');
})();

The main script reuses this session on every run. No login flow, no CAPTCHA — LinkedIn sees a returning browser with valid cookies, not a new automated client.

Extracting comments from a post requires navigating to the post's permalink, waiting for the comment thread to render, then querying the DOM. LinkedIn's class names are obfuscated (think comments-comment-item__main-content mixed with generated suffixes), so I target semantic attributes and stable data attributes instead.

async function scrapeComments(page, postUrl) {
    await page.goto(postUrl, { waitUntil: 'domcontentloaded' });

    // Expand "show more comments" if present
    const showMore = page.locator('button.comments-comments-list__show-previous-button');
    if (await showMore.isVisible()) {
        await showMore.click();
        await page.waitForTimeout(1500);
    }

    const comments = await page.evaluate(() => {
        const items = document.querySelectorAll('.comments-comment-item');
        return Array.from(items).map(item => {
            const idAttr = item.getAttribute('data-id') || '';
            const textEl = item.querySelector('.comments-comment-item__main-content');
            const authorEl = item.querySelector('.comments-post-meta__name-text');
            return {
                id: idAttr,
                author: authorEl?.innerText.trim() ?? 'Unknown',
                text: textEl?.innerText.trim() ?? '',
            };
        }).filter(c => c.id && c.text);
    });

    return comments;
}

The data-id attribute on each comment item is stable across page loads — LinkedIn uses it internally for reply threading. It's the key used in replied.json to track what has already been answered.

Threaded replies — not top-level comments

Posting a top-level comment when someone replied inside an existing thread is off-topic. The LinkedIn API supports replying inside a thread via the parentComment field.

The catch: the URN scraped from the DOM looks like urn:li:comment:(activity:X,Y), but the API expects urn:li:comment:(urn:li:activity:X,Y). One transform before the API call.

function normalizeCommentUrn(urn) {
    return urn.replace(
        /urn:li:comment:\(activity:(\d+),(\d+)\)/,
        'urn:li:comment:(urn:li:activity:$1,$2)'
    );
}

async function postThreadedReply(commentUrn, activityUrn, replyText) {
    const body = {
        actor: `urn:li:person:${LINKEDIN_PERSON_ID}`,
        message: { text: replyText },
        parentComment: normalizeCommentUrn(commentUrn),
        object: activityUrn,
    };

    const res = await fetch(
        `https://api.linkedin.com/v2/socialActions/${encodeURIComponent(activityUrn)}/comments`,
        {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${LINKEDIN_ACCESS_TOKEN}`,
                'Content-Type': 'application/json',
                'X-Restli-Protocol-Version': '2.0.0',
            },
            body: JSON.stringify(body),
        }
    );

    if (!res.ok) throw new Error(`LinkedIn API error: ${res.status}`);
}

Thread context and article context

When someone replies inside a thread, the response only makes sense if Claude knows what was said above. The bot walks up the chain — parent comment, grandparent if available — and passes the reconstructed conversation into the prompt.

If the post is in WATCHED_POSTS with a slug, the bot also loads the associated blog article — H2 headings and opening paragraphs from each section. Claude replies with knowledge of the actual subject matter, not just "backend developer" as generic context.

async function buildPromptContext(comment, post) {
    const parts = [];

    // Blog article context (if slug provided)
    if (post.slug) {
        const articleContent = await loadArticleContext(post.slug);
        if (articleContent) {
            parts.push(`Associated blog article:\n${articleContent}`);
        }
    }

    // Thread context (parent + grandparent)
    if (comment.parentUrn) {
        const parent = await fetchComment(comment.parentUrn);
        parts.push(`Parent comment by ${parent.author}:\n${parent.text}`);

        if (parent.parentUrn) {
            const grandParent = await fetchComment(parent.parentUrn);
            parts.push(`Earlier in the thread (${grandParent.author}):\n${grandParent.text}`);
        }
    }

    return parts.join('\n\n');
}

Claude CLI one-shot: the reply generation

Claude CLI accepts a prompt via stdin or as a positional argument and prints the response to stdout. That's all I needed — one child process per comment, no SDK, no token management. The spawnSync call keeps it synchronous and readable:

const { spawnSync } = require('child_process');

function generateReply(author, commentText, postContext, threadContext) {
    const prompt = [
        'You are Odilon Hugonnot, a senior full-stack developer (Go, PHP, Vue.js).',
        'You are replying to a comment on your LinkedIn post.',
        '',
        'Post context:',
        postContext,
        '',
        ...(threadContext ? ['Thread context:', threadContext, ''] : []),
        `Comment by ${author}:`,
        commentText,
        '',
        'Write a concise, natural, professional reply in the same language as the comment.',
        '— Start with "🤖 Claudilon:" to be transparent about AI authorship.',
        '— 2 to 4 sentences maximum.',
        '— No filler phrases ("Great point!", "Thanks for sharing!").',
        '— Engage with the substance of the comment.',
        'Reply only with the text of the reply, nothing else.',
    ].join('\n');

    const result = spawnSync('claude', ['--print', prompt], {
        encoding: 'utf8',
        timeout: 30_000,
    });

    if (result.error) throw result.error;
    return result.stdout.trim();
}

The --print flag tells Claude CLI to output the response and exit, without entering interactive mode. The timeout is set to 30 seconds — more than enough for a short reply, and it prevents the daemon from hanging if the API is slow.

The 🤖 Claudilon: prefix is mandatory — it's the transparency layer. Anyone reading the thread knows they're interacting with a bot. The language detection remains implicit: Claude matches the comment's language automatically.

Anti-loop, rate limiting, and the robot prefix

Without protection, the bot would reply to its own replies in an infinite loop. Without rate limiting, a suddenly viral post would trigger a burst of responses that LinkedIn would notice. Both need explicit handling.

The robot prefix doubles as the anti-loop trigger: any comment starting with "🤖 Claudilon:" or the legacy "Claudilon:" is skipped. The legacy check ensures old replies (posted before the emoji was added) don't get re-answered.

function isOwnReply(text) {
    return text.startsWith('🤖 Claudilon:') || text.startsWith('Claudilon:');
}

// Rate limits: 3/cycle, 15/hour, 50/day
const RATE_LIMITS = { perCycle: 3, perHour: 15, perDay: 50 };

function checkRateLimit(counters) {
    const now = Date.now();

    if (now - counters.hourStart > 3_600_000) { counters.hour = 0; counters.hourStart = now; }
    if (now - counters.dayStart  > 86_400_000) { counters.day  = 0; counters.dayStart  = now; }

    return (
        counters.cycle < RATE_LIMITS.perCycle &&
        counters.hour  < RATE_LIMITS.perHour  &&
        counters.day   < RATE_LIMITS.perDay
    );
}

// State: persisted across daemon restarts
const STATE_FILE = './.linkedin-comments-seen.json';

function loadSeen() {
    try {
        return new Set(JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')));
    } catch {
        return new Set();
    }
}

function saveSeen(seen) {
    fs.writeFileSync(STATE_FILE, JSON.stringify([...seen], null, 2));
}

// In the main loop:
const seen = loadSeen();

for (const comment of newComments) {
    if (seen.has(comment.urn)) continue;
    if (isOwnReply(comment.text)) continue;
    if (!checkRateLimit(counters)) break;

    const context = await buildPromptContext(comment, post);
    const reply = generateReply(comment.author, comment.text, post.text, context);
    await postThreadedReply(comment.urn, post.urn, reply);

    seen.add(comment.urn);
    saveSeen(seen);  // Persist immediately — crash safety
    counters.cycle++; counters.hour++; counters.day++;
}

Writing state after each reply (not at the end of the loop) means a crash mid-loop doesn't cause double-replies on the next run. The seen file grows indefinitely — that's acceptable for a personal account (5,000 entries after years of activity, O(1) Set lookup).

Systemd user service — persistent daemon

A script launched from a terminal dies when the session closes. For continuous operation, systemd user mode is the right tool: auto-restart on failure, centralized logs via journald, no root required.

# ~/.config/systemd/user/claudilon.service
[Unit]
Description=Claudilon LinkedIn auto-reply bot
After=network-online.target

[Service]
Type=simple
WorkingDirectory=/home/odilon/work/cv
ExecStart=/usr/bin/node scripts/linkedin-auto-reply.js --loop
Restart=on-failure
RestartSec=10s
StandardOutput=journal
StandardError=journal

# Clean shutdown: SIGTERM → wait 15s → SIGKILL
KillMode=mixed
TimeoutStopSec=15

[Install]
WantedBy=default.target
systemctl --user enable claudilon
systemctl --user start claudilon
journalctl --user -u claudilon -f

The --loop mode runs the cycle in a loop with a 30-second sleep between iterations. SIGTERM handling is explicit: the handler waits for the current cycle to finish before exiting — no interruption mid-API-call.

process.on('SIGTERM', async () => {
    console.log('SIGTERM received — waiting for current cycle...');
    running = false;
    await currentCyclePromise;
    process.exit(0);
});

Posting the reply via Playwright

Clicking "Reply" on a comment, typing the text, and submitting — exactly what a human does, just automated. LinkedIn's comment form uses a contenteditable div, not a standard <textarea>, which requires fill() rather than type().

async function postReply(page, commentId, replyText) {
    // Click the "Reply" link under the specific comment
    const commentEl = page.locator(`[data-id="${commentId}"]`);
    const replyBtn = commentEl.locator('button.comments-comment-social-bar__reply-action-button');
    await replyBtn.click();

    // Wait for the reply textarea to appear
    const replyBox = commentEl.locator('.ql-editor');
    await replyBox.waitFor({ state: 'visible', timeout: 5000 });

    // Fill the contenteditable div
    await replyBox.fill(replyText);

    // Submit
    const submitBtn = commentEl.locator('button.comments-comment-box__submit-button');
    await submitBtn.click();

    // Brief wait — lets the DOM confirm the reply landed
    await page.waitForTimeout(2000);
}

The 2-second wait after submit is a crude confirmation mechanism. A cleaner approach would be to poll for the new reply element in the DOM, but for a bot that runs every 5 minutes, the extra latency doesn't matter.

Production results and honest limitations

After two weeks running as a systemd daemon with 30-second cycles, the bot has replied to 34 comments across 6 posts. Average latency from comment to reply: 18 seconds. The hybrid notification mode cut page loads by ~70% compared to scraping each post individually. Nobody noticed it wasn't me — which I consider the correct success metric.

But the limitations are real, and worth being explicit about:

  • LinkedIn may break it silently. Any DOM change to the comment structure — class renames, attribute removals — will break the selectors. I've had one such break already, fixed in 10 minutes, but it's a maintenance overhead that doesn't exist with a proper API.
  • Thread context is partial. The bot walks up to the grandparent comment, but a long multi-turn thread still loses context beyond that. Walking the full chain would balloon the prompt — two levels up is the practical compromise.
  • Rate limiting risk. The built-in limits (3/cycle, 15/hour, 50/day) keep activity conservative. A genuinely viral post would hit the daily cap and go silent — acceptable for a personal account, not for production customer engagement.
  • No human review loop. The bot posts directly. A false-positive — misunderstood comment, wrong language detection, hallucinated context — goes live immediately. The prompt engineering reduces this but doesn't eliminate it.

For the use case of keeping a technical post's comment thread alive while I'm unavailable, the trade-off is acceptable. For anything customer-facing or reputation-sensitive, I'd add a review queue before posting.

This project connects directly to the broader automation workflow I've been building — see automating blog cross-posting to Dev.to and LinkedIn and building the automation landing page with Claude Code for the surrounding context.

Conclusion

Claudilon is around 200 lines of JavaScript. It took about 3 hours to build — 1 hour figuring out LinkedIn's DOM, 1 hour on the Claude CLI integration and prompt tuning, 1 hour on the anti-loop state and cron setup.

The interesting part isn't the code. It's that the constraint (no API) forced a design that's simpler than an API integration would have been. No OAuth dance, no token refresh, no webhook infrastructure. A browser and a JSON file.

Whether this scales to a proper product is a different question. As a personal tool for keeping a LinkedIn presence alive without spending 20 minutes a day on it, it already pays for itself.

Comments (0)