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('nn');
}

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.

How a Monorepo Keeps Multiple Projects in Sync – From Shared Code to Atomic Deployments

Copy-Paste Development

Every company that runs more than one project eventually faces the same problem.

It starts small. Project B needs the same user authentication logic as Project A, so someone copies the auth module over. A few months later, Project C launches and borrows the same code – plus some API utilities from Project B. By the time Project D kicks off, there are four slightly different implementations of the same auth flow, three versions of the same API types, and no one remembers which project has the “correct” version.

Each project drifts. Different ESLint configs. Different TypeScript strictness levels. Different folder structures. A junior developer joins Project C and learns patterns that don’t exist in Project A. A bug gets fixed in one project’s copy of a utility but never propagated to the others.

The usual answer to this is internal npm packages. Extract the shared code, publish it to a private registry, and have each project depend on it. This helps, but it introduces its own problems: now every change to shared code requires a version bump, a publish step, and a dependency update in every consuming project. Hotfixes become multi-step release processes. And if the shared package has its own dependencies, you’re now managing a dependency tree across multiple registries.

The shared code problem doesn’t go away – it just moves from copy-pasted files to versioned packages that still need coordination.

A monorepo takes a different approach. Instead of distributing shared code through a registry, it keeps everything in one repository with one source of truth for shared logic, one set of standards, and a build system that understands how projects relate to each other. Whether the projects use the same framework or different ones, the benefits are identical.

What a Multi-Project Monorepo Looks Like

The structure separates shared code from project-specific code:

apps/
  customer-portal/     → Project A
  admin-dashboard/     → Project B
  api/                 → Backend API

libs/
  shared/              → Used by everything
    contracts/         → TypeScript interfaces, DTOs, enums
    utils/             → Pure utility functions
    styles/            → SCSS variables, mixins, themes

  customer-portal/     → Project A specific libraries
    features/          → Feature modules
    shared/            → UI components, stores

  admin-dashboard/     → Project B specific libraries
    features/          → Feature modules
    shared/            → UI components, stores

Every project has its own space. Shared code lives in one place. The boundary between “mine” and “ours” is explicit in the folder structure.

In a real production codebase – a browser-based IDE (kod3.dev, Github Source Code) with multiple frontends and a NestJS backend – this breaks down to:

Layer Lines of Code % of Total
Shared (contracts, utils, terminal, styles) ~4,850 20%
Frontend A (Angular) ~8,030 33%
Frontend B (React) ~3,800 16%
Backend API (NestJS) ~3,200 13%
Application shells ~1,470 6%

That 20% of shared code is consumed by every project in the repo. Without the monorepo, those ~4,850 lines would need to exist independently in each project – copied, maintained separately, and slowly diverging. That’s roughly 10,000 lines of duplication avoided.

Benefit 1: Shared Contracts – Define Once, Use Everywhere

The highest-leverage shared code is contracts: the TypeScript interfaces that define how data flows between services and projects.

Consider a git status interface used across the entire stack:

// libs/shared/contracts/src/lib/git.contract.ts

export interface GitStatusDto {
  branch: string;
  tracking: boolean;
  ahead: number;
  behind: number;
  staged: GitFileChange[];
  unstaged: GitFileChange[];
  untracked: string[];
}

export interface GitCommitRequestDto {
  message: string;
}

export const GIT_CHANGE_EVENT = 'git:change';

This single file is the source of truth for what git status data looks like. The backend returns it:

// Backend API

import type { GitStatusDto } from '@org/shared/contracts';

@Controller('git')
export class GitController {
  @Get('status')
  async status(@Query('path') path: string): Promise<GitStatusDto> {
    return this.service.status(path);
  }
}

Project A consumes it:

// Project A (Angular)

import { GitStatusDto } from '@org/shared/contracts';

@Injectable({ providedIn: 'root' })
export class GitService {
  getStatus(path: string): Observable<ApiResult<GitStatusDto>> {
    return this.http
      .get<GitStatusDto>(`${this.API_BASE}/status`, { params: { path } })
      .pipe(apiResult());
  }
}

Project B consumes the exact same interface:

// Project B (React)

import type { GitStatusDto } from '@org/shared/contracts';

export const gitService = {
  getStatus: (path: string) =>
    apiResult(axios.get<GitStatusDto>(`${API}/status`, { params: { path } }).then((r) => r.data)),
};

Now add a new field to GitStatusDto. The TypeScript compiler immediately surfaces every file – across every project – that needs updating. All in the same build, from the same commit, before anything reaches production.

Compare this to the multi-repo alternative: update the types package, publish a new version, bump the dependency in Project A, open a PR, wait for review, merge, then repeat for Project B. If someone forgets to bump, the frontend silently ignores the new fields until a user reports something broken.

Shared Utilities: Same Logic, Tested Once

Beyond contracts, pure utility functions represent the next layer of high-value sharing:

// libs/shared/utils/src/index.ts

export * as FileUtils from './lib/file-utils/index';
export * as GitTreeUtils from './lib/git-tree-utils/git-tree.utils';
export * as MonacoUtils from './lib/monaco-utils/index';
export * as TabManager from './lib/tab-manager/tab-manager';
export * as ResizeUtils from './lib/resize-utils/resize-utils';
export { browserStorage } from './lib/browser-storage/browser-storage';
export { apiResult } from './lib/api-result/api-result';

GitTreeUtils.buildGitChangesTree() transforms flat git status data into a tree structure. It’s used by the backend building the API response AND by frontend stores processing that response. Same function, same logic, tested once, maintained in one place.

MonacoUtils configures the Monaco code editor. TabManager handles open/close/reorder logic. browserStorage wraps localStorage. None of these depend on any framework – they’re pure TypeScript that works everywhere.

In a multi-repo world, this kind of code either gets copy-pasted between projects (and slowly diverges) or gets published as an internal npm package (and becomes another thing to version, release, and keep in sync). In a monorepo, it’s just an import.

What About Validation?

There’s a tension worth addressing. Shared contracts are TypeScript interfaces – they exist only at compile time. But NestJS needs classes with runtime validation decorators to reject malformed requests. Frontends only need the type information. Where does validation logic live?

There are two solid approaches.

Approach A: Interfaces + Separate Validation Classes

The shared contract stays a pure interface. The NestJS backend creates a class that implements it and adds validation decorators:

// libs/shared/contracts (shared, pure interface)

export interface GitCommitRequestDto {
  message: string;
}
// apps/nestjs-api (backend only)

import { IsString, IsNotEmpty } from 'class-validator';
import type { GitCommitRequestDto } from '@org/shared/contracts';

export class GitCommitValidation implements GitCommitRequestDto {
  @IsString()
  @IsNotEmpty()
  message: string;
}

The implements keyword is key – if the interface changes, TypeScript forces the validation class to update too. The shared contract stays framework-agnostic. Frontends are completely unaffected.

The tradeoff is duplication: the interface says “message is a string” and the class repeats it with @IsString(). Validation rules live only in the backend.

Approach B: Zod Schemas as the Single Source of Truth

Instead of a plain interface, the shared contract exports a Zod schema. The TypeScript type is derived from the schema, so the shape and validation rules are defined once:

// libs/shared/contracts (shared, Zod schema)

import { z } from 'zod';

export const GitCommitRequestSchema = z.object({
  message: z.string().min(1, 'Commit message is required'),
});

export type GitCommitRequestDto = z.infer<typeof GitCommitRequestSchema>;
// → { message: string }

NestJS wraps the schema into a DTO using nestjs-zod:

// apps/nestjs-api (backend only)

import { createZodDto } from 'nestjs-zod';
import { GitCommitRequestSchema } from '@org/shared/contracts';

export class GitCommitDto extends createZodDto(GitCommitRequestSchema) {}

Frontends import the type exactly as before – no changes needed:

// Any frontend
import type { GitCommitRequestDto } from '@org/shared/contracts';

But they can also reuse the validation schema for client-side form validation:

// React form validation
import { GitCommitRequestSchema } from '@org/shared/contracts';

const result = GitCommitRequestSchema.safeParse(formData);
if (!result.success) {
  setErrors(result.error.format());
}

Both approaches work well in a monorepo. Approach A is simpler to adopt incrementally. Approach B pays off when validation consistency across the full stack matters.

Benefit 2: Enforced Standards – One Set of Rules for Everyone

With a multi-repo setup, each project develops its own conventions. Project A uses a feature-based folder structure, Project B organizes by file type. Project A handles HTTP errors through interceptors, Project C does it inline in every service call. State management looks completely different across projects – one uses signals, another still relies on BehaviorSubjects. Moving between projects becomes unnecessarily hard.

A monorepo eliminates this by sharing configuration files at the root level: one tsconfig.base.json, one ESLint config, one Prettier config. Every project inherits the same rules.

But shared config alone isn’t enough. The real power comes from module boundary enforcement. Nx allows tagging every project by scope and type, then defining rules for what can depend on what:

// eslint.config.mjs

'@nx/enforce-module-boundaries': ['error', {
  depConstraints: [
    {
      sourceTag: 'type:app',
      onlyDependOnLibsWithTags: [
        'type:feature', 'type:ui', 'type:util',
        'type:data-access', 'type:contracts',
      ],
    },
    {
      sourceTag: 'type:ui',
      onlyDependOnLibsWithTags: ['type:ui', 'type:contracts', 'type:util'],
    },
    {
      sourceTag: 'type:util',
      onlyDependOnLibsWithTags: ['type:util', 'type:contracts'],
    },
    {
      sourceTag: 'type:data-access',
      onlyDependOnLibsWithTags: ['type:ui', 'type:util', 'type:contracts'],
    },
  ],
}]

In plain English:

  • A utility can only import other utilities and contracts. It can never reach up to a feature or a store.
  • A UI component can import other UI components, contracts, and utils. It cannot import stores or features. This keeps components pure and reusable.
  • Data-access stores can import utils and contracts. They cannot import features – keeping state management decoupled from view logic.
  • Features are the orchestrators. They wire stores to components and can import from all library types.

If someone writes an invalid import – say a UI component importing a data-access store – the linter catches it:

error  A project tagged with "type:ui" can only depend on libs tagged with
       "type:ui", "type:contracts", "type:util"
       @nx/enforce-module-boundaries

The PR can’t merge. The architecture polices itself.

This is something no amount of documentation or code review discipline can achieve in a multi-repo. Rules that exist only in a wiki get ignored. Rules that break the build get followed.

Benefit 3: Build Only What Changed

The most common objection to monorepos is CI speed. “If everything is in one repo, doesn’t every PR build everything?”

No. Nx traces the project dependency graph and runs tasks only for projects impacted by the change:

# CI pipeline - every pull request
- run: npx nx affected -t lint build --base=$NX_BASE --head=$NX_HEAD

nx affected compares the PR branch against the base, identifies changed files, walks the dependency graph to find impacted projects, and runs tasks only for those.

What Changed What Gets Built
A feature in Project A Only Project A and its dependencies
A shared contract Every project that imports it
A shared utility Only projects that depend on that utility
A README file Nothing – no project depends on docs

On top of affected commands, Nx caches task results:

// nx.json

"targetDefaults": {
  "@angular/build:application": {
    "cache": true,
    "inputs": ["production", "^production"]
  },
  "@nx/js:swc": {
    "cache": true,
    "inputs": ["production", "^production"]
  }
}

The production named input excludes test files and configs. So editing a test file invalidates the test cache but not the build cache. The build replays from cache instantly.

With Nx Cloud, the cache becomes distributed – a build that ran on one developer’s machine is available to the entire team and CI. The first person pays the cost; everyone else gets a cache hit.

Benefit 4: Atomic Deployments

In a multi-repo world, deployments are independent. The API deploys on Tuesday, Project A on Wednesday, Project B on Thursday. For two days, something is out of sync. If the API shipped a breaking change, Project B runs against the wrong contract version until someone notices.

A monorepo deploys everything from the same commit:

# deploy.yml - triggered on push to dev

- name: Lint
  run: npx nx affected -t lint

- name: Build Project A
  run: npx nx build angular-ide --configuration=production

- name: Build Project B
  run: npx nx build react-ide

- name: Build API
  run: npx nx build nestjs-api

- name: Deploy Project A
  run: rsync -avz --delete dist/apps/angular-ide/ deploy@server:/var/www/app/angular/

- name: Deploy Project B
  run: rsync -avz --delete apps/react-ide/dist/ deploy@server:/var/www/app/react/

- name: Deploy API
  run: rsync -avz apps/nestjs-api/dist/ deploy@server:/opt/api/

- name: Restart Services
  run: ssh deploy@server "cd /opt/api && docker compose up -d --build"

One push. One pipeline. Every project deployed from the same source of truth.

There’s never a window where Project A expects a field the API hasn’t deployed yet. There’s no “which API version is this frontend running against?” question. Contracts match because they come from the same commit.

Benefit 5: Single Version Policy

External Dependencies

This might be the most underrated benefit. In a multi-repo world, each project has its own package.json. Over time, they drift. Project A upgrades to Angular 21. Project B is still on Angular 16 because no one had time to migrate. Project C is stuck on Angular 13 because it depends on a library that never updated. A developer moves from Project B to Project A and discovers that half the patterns they learned don’t exist in the older version. A security patch lands for Angular 18+ only – and two of three projects can’t receive it.

The same happens with every dependency. One project runs RxJS 7, another RxJS 6. One uses TypeScript 5.5 strict mode, another is on 4.9 with half the checks disabled. The node_modules across projects become parallel universes.

A monorepo has one package.json. One version of Angular. One version of RxJS. One version of TypeScript. When a framework upgrade happens, it happens everywhere at once. Every project gets the security patches, the performance improvements, and the new APIs at the same time. No project falls behind because “we’ll migrate later” – later never comes.

This also simplifies onboarding. A new developer learns one set of APIs, one set of patterns, one set of tooling. There’s no “which version does this project use?” question.

Internal Shared Code

The same principle applies to shared libraries within the repo. In multi-repo setups, shared packages need semantic versioning. Consumers need to bump dependencies. Breaking changes mean major version bumps, migration guides, and inevitably one project staying on the old version for months.

The worst case is the diamond dependency problem: Project A depends on Shared v2, Project B depends on Shared v1, and both talk to the same API. Two versions of the same types in play, and runtime behavior becomes unpredictable.

In a monorepo, this can’t happen. There’s one version of @org/shared/contracts – the one at HEAD. Every project uses it. A breaking change forces all consumers to update in the same commit. The reviewer sees the full picture in a single pull request.

Git history becomes meaningful across the full stack:

feat(git): add conflict detection

- Add `conflicted` field to GitStatusDto
- Update API to detect merge conflicts
- Add conflict indicators to Project A
- Add conflict indicators to Project B

One commit. Complete traceability. Reviewable and revertable as a unit.

Even Competing Frameworks Can Coexist

If shared contracts, enforced boundaries, and affected builds work across multiple projects that use the same framework – they also work across projects that use different frameworks.

The production codebase these examples come from runs Angular and React side by side. Two frameworks with fundamentally different paradigms. Angular uses dependency injection and observables. React uses hooks and promises. NestJS uses decorators and its own module system.

Yet they all import from @org/shared/contracts. They all share @org/shared/utils. They all get validated by the same ESLint rules and deployed by the same pipeline.

The same GitStatusDto flows from a NestJS controller decorated with @Get('status'), through an Angular service returning an Observable<ApiResult<GitStatusDto>>, and through a React service returning a Promise<ApiResult<GitStatusDto>>. Different HTTP clients, different paradigms, one interface governing the data shape everywhere.

If even competing frameworks can coexist in the same repo without stepping on each other – then two Angular projects sharing an auth module, three React apps sharing a design system, or five microservices sharing API contracts is a trivially simpler version of the same pattern.

The multi-framework case is the stress test. The single-framework, multi-project case is the easy win.

The Tradeoffs

This approach has real costs that should be weighed honestly:

Tooling investment. Nx has a learning curve – project tags, dependency constraints, named inputs all need upfront configuration. But this is a one-time cost that pays off on every PR after.

Shared code changes ripple. When a contract changes, every consuming project rebuilds and retests. But that’s the point – you catch breakage in CI instead of in production.

Repository size grows. More code, more history in a single repo. But nx affected and distributed caching mean CI only builds what changed, regardless of repo size.

Team coordination on shared code. Merge conflicts increase when multiple teams touch the same shared libraries. But clear ownership and thin shared layers keep this manageable – and the alternative is silent divergence across repos.

Overhead for a single project. A monorepo with one application feels like unnecessary tooling. But most companies don’t stay at one project forever. When the second project launches, the shared contracts, enforced boundaries, and build infrastructure are already in place.

About This Monorepo

The codebase used in this post (kod3.dev, source code) started as a portfolio project to showcase Nx and prove that even competing frameworks can coexist in a single repo. But it keeps growing as new ideas come in – voice control, AI integration, a terminal emulator – and the monorepo structure makes it easy to add features without reorganizing the entire project.

One thing worth noting: the libraries aren’t split into dozens of tiny packages. A shared utils lib contains FileUtils, GitTreeUtils, MonacoUtils, TabManager, and more – all in one library. What keeps this manageable is named barrel exports:

// libs/shared/utils/src/index.ts

export * as FileUtils from './lib/file-utils/index';
export * as GitTreeUtils from './lib/git-tree-utils/git-tree.utils';
export * as MonacoUtils from './lib/monaco-utils/index';
export * as TabManager from './lib/tab-manager/tab-manager';
export * as ResizeUtils from './lib/resize-utils/resize-utils';

Consumers import FileUtils.getExtension() or GitTreeUtils.buildGitChangesTree() – the namespace makes it clear where every function comes from, even when multiple utilities live in the same library.

The tradeoff is that changing a single file in shared/utils triggers a rebuild of every project that depends on the library. When that becomes a problem – when one utility changes frequently and causes unnecessary rebuilds – that’s the signal to split it into its own library. Named barrel exports make the code readable from the start, and Nx makes the split straightforward when the time comes. You don’t need to start with fifty micro-libraries to end up with a clean architecture.

Getting Started

For teams considering this approach:

1. Start with contracts. Extract shared TypeScript interfaces into a dedicated library. This is the highest-leverage move – immediate type safety across every project that consumes the same API.

2. Add module boundaries early. Tagging projects from the start is straightforward. Untangling circular dependencies six months later is not.

3. Keep the shared layer thin. Share what must be shared: contracts, pure utility functions, and cross-cutting concerns. Project-specific logic stays in project-specific directories.

4. Namespace imports clearly. Path aliases like @org/shared/contracts, @org/project-a-data-access, and @org/project-b-ui make it immediately obvious which layer and project an import belongs to.

5. Visualize dependencies regularly. nx graph renders the project dependency graph. When an unexpected arrow appears, fix it while it’s still one import to remove – not an architectural refactor.

Conclusion

A monorepo isn’t about putting files in one folder. It’s about creating a system where shared logic has one source of truth, standards are enforced by tooling instead of documentation, builds are intelligent enough to skip what hasn’t changed, and deployments are atomic across every project.

The 20% of shared code in a well-structured monorepo isn’t about reducing keystrokes. It’s about ensuring that when a DTO changes, every consumer across every project updates together, breaks together, and ships together. The alternative – copy-pasted code, diverging conventions, staggered deployments, and runtime type mismatches – costs more than any monorepo tooling ever will.

If you’re running a monorepo, considering one, or have questions about anything covered here – drop a comment below. I’d love to hear how other teams are handling shared code across multiple projects.

Understanding ISO 13485 and FDA Requirements for Transdermal Patch Manufacturing

If you’re sourcing or manufacturing transdermal patch products (heating patches, cooling gel patches, detox foot patches) for the US or European market, regulatory compliance isn’t optional—it’s the foundation of your business.
What Is ISO 13485?
ISO 13485 is the international standard for quality management systems (QMS) in medical device manufacturing. For transdermal patch products, it covers:
Design and development controls
Production process validation
Supplier quality management
Traceability systems (batch tracking)
Corrective and preventive actions (CAPA)
Document control and records management
Key Documentation You Should Request From Your Manufacturer:
ISO 13485 Certificate — Verify it on the certification body’s website
Design History File (DHF) — Documents the product development process
Device Master Record (DMR) — Complete specifications for your product
Risk Analysis (ISO 14971) — For identifying and mitigating product risks
Biocompatibility Test Reports — Skin sensitization, irritation, cytotoxicity testing
Shelf Life Validation — Accelerated aging test results
FDA Registration vs. Clearance
FDA Establishment Registration (Reg. Form 2891) — Required for all facilities manufacturing medical devices sold in the US
FDA 510(k) Clearance — Required only if your product is a Class II device that needs premarket review
Over-the-Counter (OTC) Monograph — Some pain relief patches may qualify under OTC drug monographs
Supply Chain Red Flags:
Manufacturer claims “FDA approved” but can’t provide a 510(k) number
Quality certificates from non-accredited bodies
No English-language technical documentation
Missing batch traceability records
Building a compliant supply chain takes time—typically 3-6 months for thorough vetting. Start early.

Melikgazi Belediyesi tesislerinde GÖKTİM eğitimleri başladı

Kayseri Valiliği himayelerinde kurulan Gökyüzü Teknoloji ve İnovasyon Merkezi (GÖKTİM) eğitimlerine Melikgazi Belediyesi’nin tesislerinde başlandı. Belediye Başkanı Mustafa Palancıoğlu, gençleri geleceğe en iyi şekilde hazırlamayı amaçladıklarını belirtti.

Vodafone bir günlük sınırsız internet nasıl yapılır? Vodafone hediyeli internet kampanyası ne zamana kadar geçerli?

Türkiye’de mobil internet kullanıcıları, kısa süreli fırsatları kaçırmak istemiyor. Vodafone, sunduğu 5G Sınırsız Internet Kampanyası ile kullanıcılarına bir gün boyunca sınırsız internet deneyimi yaşama şansı veriyor. Peki, Vodafone bir günlük sınırsız internet nasıl yapılır? Vodafone hediyeli internet kampanyası ne zamana kadar geçerli? Detaylar haberimizde.

Eskişehir sanayisi yüksek teknolojili üretimle öne çıkıyor

Eskişehir Organize Sanayi Bölgesi Yönetim Kurulu Başkanı Nadir Küpeli, şehirde yüksek teknolojik ürünlerin toplam ihracatın %33’ünü oluşturduğunu ve gelecekte bu rakamın daha da artacağını açıkladı. 2025 hedefleri arasında ihracatın 6 milyar dolara ulaşması yer alıyor.