@sellable/mcp 0.1.262 → 0.1.263

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -9,7 +9,7 @@ import { getCampaignTableSchema, queueCampaignCells, reviseMessageTemplateAndRer
9
9
  import { createCampaign, duplicateCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
10
10
  import { queueCells, updateCell } from "./tools/cells.js";
11
11
  import { handleStartCliLogin, handleWaitForCliLogin, } from "./tools/cli-login.js";
12
- import { capturePostIdeaTool, getPostDraftTool, getPostIdeaTool, getPublishedPostTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
12
+ import { capturePostIdeaTool, getPostDraftTool, getPostIdeaTool, getPublishedPostTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, renderLinkedInPostPreviewTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
13
13
  import { getCampaignContext, hydrateCampaignContextFromCampaign, markCampaignContextDirty, } from "./tools/context.js";
14
14
  import { addToCommentCampaign, addToConnectionCampaign, addToInmailCampaign, getEngagedPosts, getOrCreateDirectCampaignTable, pauseDirectCampaign, startDirectCampaign, } from "./tools/direct-campaigns.js";
15
15
  import { bootstrapEngage, bootstrapEngageMulti, } from "./tools/engage-bootstrap.js";
@@ -244,6 +244,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
244
244
  case "save_post_draft":
245
245
  result = savePostDraftTool(args);
246
246
  break;
247
+ case "render_linkedin_post_preview":
248
+ result = renderLinkedInPostPreviewTool(args);
249
+ break;
247
250
  case "update_post_draft":
248
251
  result = updatePostDraftTool(args);
249
252
  break;
@@ -1,4 +1,4 @@
1
- type ContentKind = "idea" | "hook_research" | "draft" | "published_post";
1
+ type ContentKind = "idea" | "hook_research" | "draft" | "published_post" | "linkedin_preview";
2
2
  type ArtifactMetadata = {
3
3
  id: string;
4
4
  type: ContentKind;
@@ -94,6 +94,12 @@ export declare const contentPostToolDefinitions: ({
94
94
  body?: undefined;
95
95
  validationReceipt?: undefined;
96
96
  status?: undefined;
97
+ postText?: undefined;
98
+ renderId?: undefined;
99
+ sourceLabel?: undefined;
100
+ renderScreenshots?: undefined;
101
+ requireScreenshots?: undefined;
102
+ reviewClampLines?: undefined;
97
103
  updatedAt?: undefined;
98
104
  publishUrl?: undefined;
99
105
  activityId?: undefined;
@@ -193,6 +199,12 @@ export declare const contentPostToolDefinitions: ({
193
199
  body?: undefined;
194
200
  validationReceipt?: undefined;
195
201
  status?: undefined;
202
+ postText?: undefined;
203
+ renderId?: undefined;
204
+ sourceLabel?: undefined;
205
+ renderScreenshots?: undefined;
206
+ requireScreenshots?: undefined;
207
+ reviewClampLines?: undefined;
196
208
  updatedAt?: undefined;
197
209
  publishUrl?: undefined;
198
210
  activityId?: undefined;
@@ -215,6 +227,7 @@ export declare const contentPostToolDefinitions: ({
215
227
  properties: {
216
228
  draftId: {
217
229
  type: string;
230
+ description?: undefined;
218
231
  };
219
232
  ideaId: {
220
233
  type: string;
@@ -259,6 +272,12 @@ export declare const contentPostToolDefinitions: ({
259
272
  selectedPatterns?: undefined;
260
273
  previewBudget?: undefined;
261
274
  notes?: undefined;
275
+ postText?: undefined;
276
+ renderId?: undefined;
277
+ sourceLabel?: undefined;
278
+ renderScreenshots?: undefined;
279
+ requireScreenshots?: undefined;
280
+ reviewClampLines?: undefined;
262
281
  updatedAt?: undefined;
263
282
  publishUrl?: undefined;
264
283
  activityId?: undefined;
@@ -279,8 +298,79 @@ export declare const contentPostToolDefinitions: ({
279
298
  inputSchema: {
280
299
  type: string;
281
300
  properties: {
301
+ postText: {
302
+ type: string;
303
+ description: string;
304
+ };
282
305
  draftId: {
283
306
  type: string;
307
+ description: string;
308
+ };
309
+ renderId: {
310
+ type: string;
311
+ description: string;
312
+ };
313
+ sourceLabel: {
314
+ type: string;
315
+ };
316
+ createdAt: {
317
+ type: string;
318
+ };
319
+ renderScreenshots: {
320
+ type: string;
321
+ description: string;
322
+ };
323
+ requireScreenshots: {
324
+ type: string;
325
+ description: string;
326
+ };
327
+ reviewClampLines: {
328
+ type: string;
329
+ description: string;
330
+ };
331
+ rawSource?: undefined;
332
+ title?: undefined;
333
+ distilledBrief?: undefined;
334
+ ideaId?: undefined;
335
+ sourceType?: undefined;
336
+ sourceUrl?: undefined;
337
+ capturedAt?: undefined;
338
+ researchId?: undefined;
339
+ topic?: undefined;
340
+ keywords?: undefined;
341
+ sourcePosts?: undefined;
342
+ selectedPatterns?: undefined;
343
+ previewBudget?: undefined;
344
+ notes?: undefined;
345
+ hookResearchId?: undefined;
346
+ priorDraftId?: undefined;
347
+ iteration?: undefined;
348
+ body?: undefined;
349
+ validationReceipt?: undefined;
350
+ status?: undefined;
351
+ updatedAt?: undefined;
352
+ publishUrl?: undefined;
353
+ activityId?: undefined;
354
+ publishedAt?: undefined;
355
+ finalText?: undefined;
356
+ updateDraftStatus?: undefined;
357
+ publishedPostId?: undefined;
358
+ year?: undefined;
359
+ metrics?: undefined;
360
+ note?: undefined;
361
+ };
362
+ required: never[];
363
+ additionalProperties: boolean;
364
+ };
365
+ } | {
366
+ name: string;
367
+ description: string;
368
+ inputSchema: {
369
+ type: string;
370
+ properties: {
371
+ draftId: {
372
+ type: string;
373
+ description?: undefined;
284
374
  };
285
375
  hookResearchId: {
286
376
  type: string;
@@ -323,6 +413,12 @@ export declare const contentPostToolDefinitions: ({
323
413
  previewBudget?: undefined;
324
414
  notes?: undefined;
325
415
  createdAt?: undefined;
416
+ postText?: undefined;
417
+ renderId?: undefined;
418
+ sourceLabel?: undefined;
419
+ renderScreenshots?: undefined;
420
+ requireScreenshots?: undefined;
421
+ reviewClampLines?: undefined;
326
422
  publishUrl?: undefined;
327
423
  activityId?: undefined;
328
424
  publishedAt?: undefined;
@@ -344,6 +440,7 @@ export declare const contentPostToolDefinitions: ({
344
440
  properties: {
345
441
  draftId: {
346
442
  type: string;
443
+ description?: undefined;
347
444
  };
348
445
  publishUrl: {
349
446
  type: string;
@@ -384,6 +481,12 @@ export declare const contentPostToolDefinitions: ({
384
481
  body?: undefined;
385
482
  validationReceipt?: undefined;
386
483
  status?: undefined;
484
+ postText?: undefined;
485
+ renderId?: undefined;
486
+ sourceLabel?: undefined;
487
+ renderScreenshots?: undefined;
488
+ requireScreenshots?: undefined;
489
+ reviewClampLines?: undefined;
387
490
  updatedAt?: undefined;
388
491
  publishedPostId?: undefined;
389
492
  year?: undefined;
@@ -436,6 +539,12 @@ export declare const contentPostToolDefinitions: ({
436
539
  body?: undefined;
437
540
  validationReceipt?: undefined;
438
541
  status?: undefined;
542
+ postText?: undefined;
543
+ renderId?: undefined;
544
+ sourceLabel?: undefined;
545
+ renderScreenshots?: undefined;
546
+ requireScreenshots?: undefined;
547
+ reviewClampLines?: undefined;
439
548
  updatedAt?: undefined;
440
549
  publishUrl?: undefined;
441
550
  activityId?: undefined;
@@ -455,6 +564,7 @@ export declare function ensureContentLayout(root?: string): {
455
564
  hookResearch: "linkedin/research/hooks";
456
565
  drafts: "linkedin/drafts";
457
566
  published: "linkedin/published";
567
+ previews: "linkedin/previews";
458
568
  commentLibrary: "linkedin/comments/library";
459
569
  };
460
570
  };
@@ -529,6 +639,117 @@ export declare function savePostDraftTool(input: {
529
639
  updatedAt: string;
530
640
  preview: string;
531
641
  };
642
+ export declare function renderLinkedInPostPreviewTool(input: {
643
+ postText?: string;
644
+ draftId?: string;
645
+ renderId?: string;
646
+ sourceLabel?: string;
647
+ createdAt?: string;
648
+ renderScreenshots?: boolean;
649
+ requireScreenshots?: boolean;
650
+ reviewClampLines?: number;
651
+ }): {
652
+ id: string;
653
+ path: string;
654
+ artifactDir: string;
655
+ absoluteArtifactDir: string;
656
+ combinedHtmlPath: string;
657
+ absoluteCombinedHtmlPath: string;
658
+ mobile: {
659
+ absoluteHtmlPath: string;
660
+ absoluteScreenshotPath: string | undefined;
661
+ textWidthPx: number;
662
+ viewportWidthPx: number;
663
+ fontSizePx: number;
664
+ lineHeightPx: number;
665
+ reviewClampLines: number;
666
+ estimatedVisibleTextBlock: string;
667
+ estimatedRenderedLines: string[];
668
+ estimatedLineCountBeforeClamp: number;
669
+ estimatedBlankLinesBeforeClamp: number;
670
+ htmlPath: string;
671
+ screenshotPath: string | undefined;
672
+ };
673
+ desktop: {
674
+ absoluteHtmlPath: string;
675
+ absoluteScreenshotPath: string | undefined;
676
+ textWidthPx: number;
677
+ viewportWidthPx: number;
678
+ fontSizePx: number;
679
+ lineHeightPx: number;
680
+ reviewClampLines: number;
681
+ estimatedVisibleTextBlock: string;
682
+ estimatedRenderedLines: string[];
683
+ estimatedLineCountBeforeClamp: number;
684
+ estimatedBlankLinesBeforeClamp: number;
685
+ htmlPath: string;
686
+ screenshotPath: string | undefined;
687
+ };
688
+ screenshotStatus: {
689
+ status: "rendered";
690
+ chromePath: string;
691
+ } | {
692
+ status: "skipped";
693
+ reason: string;
694
+ } | {
695
+ status: "failed";
696
+ reason: string;
697
+ chromePath?: string;
698
+ };
699
+ renderedPreview: {
700
+ basis: string;
701
+ cssContractVersion: string;
702
+ generatedAt: string;
703
+ sourceLabel: string;
704
+ mobile: {
705
+ textWidthPx: number;
706
+ viewportWidthPx: number;
707
+ fontSizePx: number;
708
+ lineHeightPx: number;
709
+ reviewClampLines: number;
710
+ estimatedVisibleTextBlock: string;
711
+ estimatedRenderedLines: string[];
712
+ estimatedLineCountBeforeClamp: number;
713
+ estimatedBlankLinesBeforeClamp: number;
714
+ htmlPath: string;
715
+ screenshotPath: string | undefined;
716
+ };
717
+ desktop: {
718
+ textWidthPx: number;
719
+ viewportWidthPx: number;
720
+ fontSizePx: number;
721
+ lineHeightPx: number;
722
+ reviewClampLines: number;
723
+ estimatedVisibleTextBlock: string;
724
+ estimatedRenderedLines: string[];
725
+ estimatedLineCountBeforeClamp: number;
726
+ estimatedBlankLinesBeforeClamp: number;
727
+ htmlPath: string;
728
+ screenshotPath: string | undefined;
729
+ };
730
+ diagnostics: {
731
+ charCount: number;
732
+ charCountIncludingNewlines: number;
733
+ firstLineChars: number;
734
+ firstTwoPhysicalLinesChars: number;
735
+ physicalLineCount: number;
736
+ contentLineCount: number;
737
+ longestNonblankLineChars: number;
738
+ blankLineCount: number;
739
+ };
740
+ screenshotStatus: {
741
+ status: "rendered";
742
+ chromePath: string;
743
+ } | {
744
+ status: "skipped";
745
+ reason: string;
746
+ } | {
747
+ status: "failed";
748
+ reason: string;
749
+ chromePath?: string;
750
+ };
751
+ };
752
+ };
532
753
  export declare function updatePostDraftTool(input: {
533
754
  draftId: string;
534
755
  hookResearchId?: string;
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { pathToFileURL } from "node:url";
4
6
  const CONTENT_ROOT_ENV = "SELLABLE_CONTENT_DIR";
5
7
  const DEFAULT_PREVIEW_CHARS = 220;
6
8
  const RELATIVE_DIRS = {
@@ -8,6 +10,7 @@ const RELATIVE_DIRS = {
8
10
  hookResearch: "linkedin/research/hooks",
9
11
  drafts: "linkedin/drafts",
10
12
  published: "linkedin/published",
13
+ previews: "linkedin/previews",
11
14
  commentLibrary: "linkedin/comments/library",
12
15
  };
13
16
  export const contentPostToolDefinitions = [
@@ -104,6 +107,43 @@ export const contentPostToolDefinitions = [
104
107
  additionalProperties: false,
105
108
  },
106
109
  },
110
+ {
111
+ name: "render_linkedin_post_preview",
112
+ description: "Render any LinkedIn post text through the Sellable LinkedIn preview CSS contract for mobile and desktop. Writes HTML artifacts and, when local Chrome is available, browser screenshots under ~/.sellable/content/linkedin/previews.",
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ postText: {
117
+ type: "string",
118
+ description: "Full post text to render. Provide either postText or draftId.",
119
+ },
120
+ draftId: {
121
+ type: "string",
122
+ description: "Optional saved draft ID. When postText is omitted, the tool renders this draft's Draft Body.",
123
+ },
124
+ renderId: {
125
+ type: "string",
126
+ description: "Optional stable render ID. Must be a safe filename without slashes.",
127
+ },
128
+ sourceLabel: { type: "string" },
129
+ createdAt: { type: "string" },
130
+ renderScreenshots: {
131
+ type: "boolean",
132
+ description: "Defaults to true. When true, tries to use local Chrome headless to write PNG screenshots.",
133
+ },
134
+ requireScreenshots: {
135
+ type: "boolean",
136
+ description: "Defaults to false. When true, fail if local Chrome cannot produce PNG screenshots.",
137
+ },
138
+ reviewClampLines: {
139
+ type: "number",
140
+ description: "Visible review lines before see-more. Defaults to 3.",
141
+ },
142
+ },
143
+ required: [],
144
+ additionalProperties: false,
145
+ },
146
+ },
107
147
  {
108
148
  name: "update_post_draft",
109
149
  description: "Update an existing LinkedIn post draft in place while preserving omitted fields. Useful for adding iteration receipts, status changes, or final copy edits without rewriting the whole artifact.",
@@ -389,6 +429,109 @@ export function savePostDraftTool(input) {
389
429
  preview: sanitizedPreview(input.body),
390
430
  };
391
431
  }
432
+ export function renderLinkedInPostPreviewTool(input) {
433
+ const now = normalizeDate(input.createdAt);
434
+ const draftId = input.draftId
435
+ ? normalizeArtifactId(input.draftId, "draftId")
436
+ : undefined;
437
+ const draftBody = draftId
438
+ ? extractMarkdownSection(getArtifact(RELATIVE_DIRS.drafts, draftId, "draftId").markdown, "Draft Body")
439
+ : undefined;
440
+ const postText = input.postText ?? draftBody;
441
+ requireString(postText, "postText");
442
+ const reviewClampLines = typeof input.reviewClampLines === "number" &&
443
+ Number.isFinite(input.reviewClampLines)
444
+ ? Math.max(1, Math.min(Math.floor(input.reviewClampLines), 8))
445
+ : 3;
446
+ const id = input.renderId ??
447
+ `preview_${dateStamp(now)}_${slugify(input.sourceLabel || draftId || postText)}`;
448
+ const safeId = normalizeArtifactId(id, "renderId");
449
+ const baseDir = `${RELATIVE_DIRS.previews}/${safeId}`;
450
+ const title = input.sourceLabel || draftId || safeId;
451
+ const renderScreenshots = input.renderScreenshots !== false;
452
+ const mobile = buildPreviewSurface({
453
+ label: "mobile",
454
+ text: postText,
455
+ baseDir,
456
+ textWidthPx: 308,
457
+ viewportWidthPx: 390,
458
+ viewportHeightPx: 760,
459
+ reviewClampLines,
460
+ });
461
+ const desktop = buildPreviewSurface({
462
+ label: "desktop",
463
+ text: postText,
464
+ baseDir,
465
+ textWidthPx: 582,
466
+ viewportWidthPx: 744,
467
+ viewportHeightPx: 760,
468
+ reviewClampLines,
469
+ });
470
+ const combinedHtmlRelativePath = `${baseDir}/preview.html`;
471
+ const combinedHtml = buildCombinedPreviewHtml({
472
+ title,
473
+ postText,
474
+ mobile,
475
+ desktop,
476
+ });
477
+ writeContentFile(combinedHtmlRelativePath, combinedHtml);
478
+ const screenshotResult = renderScreenshots
479
+ ? renderPreviewScreenshots([mobile, desktop], input.requireScreenshots)
480
+ : { status: "skipped", reason: "renderScreenshots=false" };
481
+ const metadata = {
482
+ id: safeId,
483
+ type: "linkedin_preview",
484
+ status: screenshotResult.status === "rendered" ? "rendered" : "html_rendered",
485
+ title,
486
+ draftId,
487
+ createdAt: now,
488
+ updatedAt: now,
489
+ };
490
+ const renderedPreview = {
491
+ basis: screenshotResult.status === "rendered"
492
+ ? "local_chrome_headless_screenshot"
493
+ : "linkedin_css_contract_html",
494
+ cssContractVersion: "linkedin-preview-rendering/v1",
495
+ generatedAt: now,
496
+ sourceLabel: title,
497
+ mobile: surfaceRecord(mobile),
498
+ desktop: surfaceRecord(desktop),
499
+ diagnostics: previewDiagnostics(postText),
500
+ screenshotStatus: screenshotResult,
501
+ };
502
+ const recordRelativePath = `${baseDir}/render.md`;
503
+ const metadataJsonRelativePath = `${baseDir}/metadata.json`;
504
+ writeContentFile(metadataJsonRelativePath, `${JSON.stringify(renderedPreview, null, 2)}\n`);
505
+ writeArtifact(recordRelativePath, buildMarkdown(metadata, [
506
+ ["Rendered Preview Record", jsonBlock(renderedPreview)],
507
+ [
508
+ "Artifacts",
509
+ [
510
+ `- combinedHtml: ${combinedHtmlRelativePath}`,
511
+ `- mobileHtml: ${mobile.htmlPath}`,
512
+ mobile.screenshotPath
513
+ ? `- mobileScreenshot: ${mobile.screenshotPath}`
514
+ : "- mobileScreenshot: none",
515
+ `- desktopHtml: ${desktop.htmlPath}`,
516
+ desktop.screenshotPath
517
+ ? `- desktopScreenshot: ${desktop.screenshotPath}`
518
+ : "- desktopScreenshot: none",
519
+ ].join("\n"),
520
+ ],
521
+ ]));
522
+ return {
523
+ id: safeId,
524
+ path: recordRelativePath,
525
+ artifactDir: baseDir,
526
+ absoluteArtifactDir: safePath(resolveContentRoot(), baseDir),
527
+ combinedHtmlPath: combinedHtmlRelativePath,
528
+ absoluteCombinedHtmlPath: safePath(resolveContentRoot(), combinedHtmlRelativePath),
529
+ mobile: surfaceReturnRecord(mobile),
530
+ desktop: surfaceReturnRecord(desktop),
531
+ screenshotStatus: screenshotResult,
532
+ renderedPreview,
533
+ };
534
+ }
392
535
  export function updatePostDraftTool(input) {
393
536
  const safeDraftId = normalizeArtifactId(input.draftId, "draftId");
394
537
  const relativePath = `${RELATIVE_DIRS.drafts}/${safeDraftId}.md`;
@@ -617,6 +760,362 @@ export function listPublishedPostsTool(input) {
617
760
  }
618
761
  return summarizeArtifacts(artifacts, input?.limit);
619
762
  }
763
+ function buildPreviewSurface(input) {
764
+ const estimatedLines = estimateRenderedLines(input.text, input.textWidthPx);
765
+ const htmlPath = `${input.baseDir}/${input.label}.html`;
766
+ const surface = {
767
+ label: input.label,
768
+ textWidthPx: input.textWidthPx,
769
+ viewportWidthPx: input.viewportWidthPx,
770
+ viewportHeightPx: input.viewportHeightPx,
771
+ fontSizePx: 14,
772
+ lineHeightPx: 21,
773
+ reviewClampLines: input.reviewClampLines,
774
+ estimatedLines,
775
+ estimatedVisibleTextBlock: estimatedLines
776
+ .slice(0, input.reviewClampLines)
777
+ .join("\n"),
778
+ estimatedLineCountBeforeClamp: estimatedLines.length,
779
+ estimatedBlankLinesBeforeClamp: estimatedLines
780
+ .slice(0, input.reviewClampLines)
781
+ .filter((line) => line.trim().length === 0).length,
782
+ htmlPath,
783
+ absoluteHtmlPath: safePath(resolveContentRoot(), htmlPath),
784
+ };
785
+ writeContentFile(htmlPath, buildSinglePreviewHtml({
786
+ title: `${input.label} LinkedIn preview`,
787
+ postText: input.text,
788
+ surface,
789
+ }));
790
+ return surface;
791
+ }
792
+ function buildSinglePreviewHtml(input) {
793
+ return htmlDocument({
794
+ title: input.title,
795
+ bodyClass: input.surface.label,
796
+ body: linkedInCardHtml(input.postText, input.surface),
797
+ });
798
+ }
799
+ function buildCombinedPreviewHtml(input) {
800
+ return htmlDocument({
801
+ title: `LinkedIn preview: ${input.title}`,
802
+ bodyClass: "combined",
803
+ body: [
804
+ `<main class="combined-grid">`,
805
+ `<section><h1>Mobile preview</h1>${linkedInCardHtml(input.postText, input.mobile)}</section>`,
806
+ `<section><h1>Desktop preview</h1>${linkedInCardHtml(input.postText, input.desktop)}</section>`,
807
+ `</main>`,
808
+ ].join("\n"),
809
+ });
810
+ }
811
+ function htmlDocument(input) {
812
+ return [
813
+ "<!doctype html>",
814
+ '<html lang="en">',
815
+ "<head>",
816
+ '<meta charset="utf-8">',
817
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
818
+ `<title>${escapeHtml(input.title)}</title>`,
819
+ "<style>",
820
+ linkedinPreviewCss(),
821
+ "</style>",
822
+ "</head>",
823
+ `<body class="${escapeHtml(input.bodyClass)}">`,
824
+ input.body,
825
+ "</body>",
826
+ "</html>",
827
+ "",
828
+ ].join("\n");
829
+ }
830
+ function linkedInCardHtml(postText, surface) {
831
+ const escapedText = escapeHtml(postText);
832
+ const cardWidth = surface.textWidthPx + 32;
833
+ return [
834
+ `<article class="linkedin-card" data-surface="${surface.label}" style="width:${cardWidth}px">`,
835
+ '<header class="post-header" aria-hidden="true">',
836
+ '<div class="avatar"></div>',
837
+ '<div class="byline"><div class="name">Preview Author</div><div class="meta">LinkedIn preview contract</div></div>',
838
+ "</header>",
839
+ `<div class="post-text" style="width:${surface.textWidthPx}px;-webkit-line-clamp:${surface.reviewClampLines}">${escapedText}</div>`,
840
+ surface.estimatedLineCountBeforeClamp > surface.reviewClampLines
841
+ ? '<div class="see-more">...see more</div>'
842
+ : "",
843
+ '<div class="post-actions" aria-hidden="true"><span>Like</span><span>Comment</span><span>Repost</span></div>',
844
+ "</article>",
845
+ ].join("\n");
846
+ }
847
+ function linkedinPreviewCss() {
848
+ return `
849
+ :root {
850
+ color-scheme: light;
851
+ --linkedin-text: rgba(0, 0, 0, 0.9);
852
+ --linkedin-muted: rgba(0, 0, 0, 0.6);
853
+ --linkedin-border: #e0dfdc;
854
+ --linkedin-bg: #f3f2ef;
855
+ }
856
+ * { box-sizing: border-box; }
857
+ body {
858
+ margin: 0;
859
+ min-height: 100vh;
860
+ background: var(--linkedin-bg);
861
+ font-family: -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue",
862
+ "Fira Sans", Ubuntu, "Oxygen Sans", Cantarell, "Droid Sans",
863
+ "Lucida Grande", Helvetica, Arial, sans-serif;
864
+ }
865
+ body.mobile, body.desktop {
866
+ display: flex;
867
+ align-items: flex-start;
868
+ justify-content: center;
869
+ padding: 20px;
870
+ }
871
+ .combined-grid {
872
+ display: grid;
873
+ grid-template-columns: max-content max-content;
874
+ gap: 28px;
875
+ align-items: start;
876
+ padding: 20px;
877
+ }
878
+ h1 {
879
+ margin: 0 0 10px;
880
+ font-size: 14px;
881
+ line-height: 20px;
882
+ font-weight: 600;
883
+ color: var(--linkedin-muted);
884
+ }
885
+ .linkedin-card {
886
+ background: #fff;
887
+ border: 1px solid var(--linkedin-border);
888
+ border-radius: 8px;
889
+ padding: 12px 16px 10px;
890
+ color: var(--linkedin-text);
891
+ }
892
+ .post-header {
893
+ display: flex;
894
+ align-items: center;
895
+ gap: 8px;
896
+ margin-bottom: 8px;
897
+ }
898
+ .avatar {
899
+ width: 40px;
900
+ height: 40px;
901
+ border-radius: 50%;
902
+ background: linear-gradient(135deg, #d7dce0, #eef0f2);
903
+ }
904
+ .byline { min-width: 0; }
905
+ .name {
906
+ height: 16px;
907
+ font-size: 14px;
908
+ line-height: 16px;
909
+ font-weight: 600;
910
+ }
911
+ .meta {
912
+ font-size: 12px;
913
+ line-height: 14px;
914
+ color: var(--linkedin-muted);
915
+ }
916
+ .post-text {
917
+ font-size: 14px;
918
+ line-height: 21px;
919
+ font-weight: 400;
920
+ letter-spacing: normal;
921
+ white-space: pre-wrap;
922
+ overflow-wrap: break-word;
923
+ color: var(--linkedin-text);
924
+ overflow: hidden;
925
+ display: -webkit-box;
926
+ -webkit-box-orient: vertical;
927
+ }
928
+ .see-more {
929
+ margin-top: 2px;
930
+ font-size: 14px;
931
+ line-height: 21px;
932
+ color: var(--linkedin-muted);
933
+ }
934
+ .post-actions {
935
+ display: flex;
936
+ gap: 18px;
937
+ border-top: 1px solid var(--linkedin-border);
938
+ margin-top: 10px;
939
+ padding-top: 8px;
940
+ color: var(--linkedin-muted);
941
+ font-size: 13px;
942
+ line-height: 18px;
943
+ }
944
+ `;
945
+ }
946
+ function renderPreviewScreenshots(surfaces, requireScreenshots) {
947
+ const chromePath = findChromeExecutable();
948
+ if (!chromePath) {
949
+ if (requireScreenshots) {
950
+ throw new Error("Chrome executable not found. Set CHROME_PATH or install Google Chrome to render LinkedIn preview screenshots.");
951
+ }
952
+ return { status: "skipped", reason: "chrome_not_found" };
953
+ }
954
+ try {
955
+ for (const surface of surfaces) {
956
+ const screenshotPath = surface.htmlPath.replace(/\.html$/, ".png");
957
+ const absoluteScreenshotPath = safePath(resolveContentRoot(), screenshotPath);
958
+ execFileSync(chromePath, [
959
+ "--headless=new",
960
+ "--disable-gpu",
961
+ "--no-sandbox",
962
+ "--disable-dev-shm-usage",
963
+ "--hide-scrollbars",
964
+ `--screenshot=${absoluteScreenshotPath}`,
965
+ `--window-size=${surface.viewportWidthPx},${surface.viewportHeightPx}`,
966
+ pathToFileURL(surface.absoluteHtmlPath).href,
967
+ ], { stdio: "ignore", timeout: 20000 });
968
+ surface.screenshotPath = screenshotPath;
969
+ surface.absoluteScreenshotPath = absoluteScreenshotPath;
970
+ }
971
+ return { status: "rendered", chromePath };
972
+ }
973
+ catch (error) {
974
+ const reason = error instanceof Error ? error.message : String(error);
975
+ if (requireScreenshots) {
976
+ throw new Error(`Chrome screenshot render failed: ${reason}`);
977
+ }
978
+ return { status: "failed", reason, chromePath };
979
+ }
980
+ }
981
+ function findChromeExecutable() {
982
+ const candidates = [
983
+ process.env.CHROME_PATH,
984
+ process.env.GOOGLE_CHROME_BIN,
985
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
986
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
987
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
988
+ "/usr/bin/google-chrome",
989
+ "/usr/bin/google-chrome-stable",
990
+ "/usr/bin/chromium",
991
+ "/usr/bin/chromium-browser",
992
+ "/snap/bin/chromium",
993
+ ].filter(Boolean);
994
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
995
+ }
996
+ function surfaceRecord(surface) {
997
+ return {
998
+ textWidthPx: surface.textWidthPx,
999
+ viewportWidthPx: surface.viewportWidthPx,
1000
+ fontSizePx: surface.fontSizePx,
1001
+ lineHeightPx: surface.lineHeightPx,
1002
+ reviewClampLines: surface.reviewClampLines,
1003
+ estimatedVisibleTextBlock: surface.estimatedVisibleTextBlock,
1004
+ estimatedRenderedLines: surface.estimatedLines.slice(0, surface.reviewClampLines),
1005
+ estimatedLineCountBeforeClamp: surface.estimatedLineCountBeforeClamp,
1006
+ estimatedBlankLinesBeforeClamp: surface.estimatedBlankLinesBeforeClamp,
1007
+ htmlPath: surface.htmlPath,
1008
+ screenshotPath: surface.screenshotPath,
1009
+ };
1010
+ }
1011
+ function surfaceReturnRecord(surface) {
1012
+ return {
1013
+ ...surfaceRecord(surface),
1014
+ absoluteHtmlPath: surface.absoluteHtmlPath,
1015
+ absoluteScreenshotPath: surface.absoluteScreenshotPath,
1016
+ };
1017
+ }
1018
+ function previewDiagnostics(text) {
1019
+ const physicalLines = text.split("\n");
1020
+ const nonblankLines = physicalLines.filter((line) => line.trim().length > 0);
1021
+ return {
1022
+ charCount: text.replace(/\s/g, "").length,
1023
+ charCountIncludingNewlines: text.length,
1024
+ firstLineChars: physicalLines[0]?.length ?? 0,
1025
+ firstTwoPhysicalLinesChars: physicalLines.slice(0, 2).join("\n").length,
1026
+ physicalLineCount: physicalLines.length,
1027
+ contentLineCount: nonblankLines.length,
1028
+ longestNonblankLineChars: nonblankLines.reduce((max, line) => Math.max(max, line.length), 0),
1029
+ blankLineCount: physicalLines.length - nonblankLines.length,
1030
+ };
1031
+ }
1032
+ function estimateRenderedLines(text, textWidthPx) {
1033
+ const physicalLines = text.split("\n");
1034
+ const rendered = [];
1035
+ for (const physicalLine of physicalLines) {
1036
+ if (physicalLine.length === 0) {
1037
+ rendered.push("");
1038
+ continue;
1039
+ }
1040
+ rendered.push(...wrapPhysicalLine(physicalLine, textWidthPx));
1041
+ }
1042
+ return rendered;
1043
+ }
1044
+ function wrapPhysicalLine(line, textWidthPx) {
1045
+ const tokens = line.match(/\S+\s*/g) ?? [line];
1046
+ const wrapped = [];
1047
+ let current = "";
1048
+ for (const token of tokens) {
1049
+ const candidate = `${current}${token}`;
1050
+ if (!current || measureTextPx(candidate) <= textWidthPx) {
1051
+ current = candidate;
1052
+ continue;
1053
+ }
1054
+ wrapped.push(current.trimEnd());
1055
+ if (measureTextPx(token) <= textWidthPx) {
1056
+ current = token;
1057
+ }
1058
+ else {
1059
+ const broken = breakLongToken(token.trimEnd(), textWidthPx);
1060
+ wrapped.push(...broken.slice(0, -1));
1061
+ current = broken.at(-1) ?? "";
1062
+ if (token.endsWith(" "))
1063
+ current += " ";
1064
+ }
1065
+ }
1066
+ if (current.length > 0)
1067
+ wrapped.push(current.trimEnd());
1068
+ return wrapped.length > 0 ? wrapped : [""];
1069
+ }
1070
+ function breakLongToken(token, textWidthPx) {
1071
+ const parts = [];
1072
+ let current = "";
1073
+ for (const char of [...token]) {
1074
+ const candidate = `${current}${char}`;
1075
+ if (!current || measureTextPx(candidate) <= textWidthPx) {
1076
+ current = candidate;
1077
+ }
1078
+ else {
1079
+ parts.push(current);
1080
+ current = char;
1081
+ }
1082
+ }
1083
+ if (current)
1084
+ parts.push(current);
1085
+ return parts;
1086
+ }
1087
+ function measureTextPx(value) {
1088
+ let width = 0;
1089
+ for (const char of [...value]) {
1090
+ width += estimateCharWidthPx(char);
1091
+ }
1092
+ return width;
1093
+ }
1094
+ function estimateCharWidthPx(char) {
1095
+ if (char === " ")
1096
+ return 3.6;
1097
+ if (/[ilI|.,:;!'`]/.test(char))
1098
+ return 3.8;
1099
+ if (/[fjrt()[\]{}]/.test(char))
1100
+ return 5.2;
1101
+ if (/[mwMW@#%&]/.test(char))
1102
+ return 10.2;
1103
+ if (/[A-Z]/.test(char))
1104
+ return 8.2;
1105
+ if (/[0-9]/.test(char))
1106
+ return 7.3;
1107
+ if (/[^\x00-\x7F]/.test(char))
1108
+ return 8.4;
1109
+ return 7.0;
1110
+ }
1111
+ function escapeHtml(value) {
1112
+ return value
1113
+ .replace(/&/g, "&amp;")
1114
+ .replace(/</g, "&lt;")
1115
+ .replace(/>/g, "&gt;")
1116
+ .replace(/"/g, "&quot;")
1117
+ .replace(/'/g, "&#39;");
1118
+ }
620
1119
  function getArtifact(relativeDir, id, label) {
621
1120
  const safeId = normalizeArtifactId(id, label);
622
1121
  const relativePath = `${relativeDir}/${safeId}.md`;
@@ -810,6 +1309,11 @@ function writeArtifact(relativePath, markdown) {
810
1309
  const filePath = safeWritablePath(root, relativePath);
811
1310
  fs.writeFileSync(filePath, markdown);
812
1311
  }
1312
+ function writeContentFile(relativePath, contents) {
1313
+ const root = resolveContentRoot();
1314
+ const filePath = safeWritablePath(root, relativePath);
1315
+ fs.writeFileSync(filePath, contents);
1316
+ }
813
1317
  function safePath(root, relativePath) {
814
1318
  validateRelativePath(relativePath);
815
1319
  const fullPath = path.resolve(root, ...relativePath.split("/"));
@@ -1260,6 +1260,12 @@ export declare const allTools: ({
1260
1260
  body?: undefined;
1261
1261
  validationReceipt?: undefined;
1262
1262
  status?: undefined;
1263
+ postText?: undefined;
1264
+ renderId?: undefined;
1265
+ sourceLabel?: undefined;
1266
+ renderScreenshots?: undefined;
1267
+ requireScreenshots?: undefined;
1268
+ reviewClampLines?: undefined;
1263
1269
  updatedAt?: undefined;
1264
1270
  publishUrl?: undefined;
1265
1271
  activityId?: undefined;
@@ -1295,6 +1301,7 @@ export declare const allTools: ({
1295
1301
  properties: {
1296
1302
  draftId: {
1297
1303
  type: string;
1304
+ description?: undefined;
1298
1305
  };
1299
1306
  ideaId: {
1300
1307
  type: string;
@@ -1339,6 +1346,12 @@ export declare const allTools: ({
1339
1346
  selectedPatterns?: undefined;
1340
1347
  previewBudget?: undefined;
1341
1348
  notes?: undefined;
1349
+ postText?: undefined;
1350
+ renderId?: undefined;
1351
+ sourceLabel?: undefined;
1352
+ renderScreenshots?: undefined;
1353
+ requireScreenshots?: undefined;
1354
+ reviewClampLines?: undefined;
1342
1355
  updatedAt?: undefined;
1343
1356
  publishUrl?: undefined;
1344
1357
  activityId?: undefined;
@@ -1361,6 +1374,7 @@ export declare const allTools: ({
1361
1374
  properties: {
1362
1375
  draftId: {
1363
1376
  type: string;
1377
+ description?: undefined;
1364
1378
  };
1365
1379
  hookResearchId: {
1366
1380
  type: string;
@@ -1403,6 +1417,12 @@ export declare const allTools: ({
1403
1417
  previewBudget?: undefined;
1404
1418
  notes?: undefined;
1405
1419
  createdAt?: undefined;
1420
+ postText?: undefined;
1421
+ renderId?: undefined;
1422
+ sourceLabel?: undefined;
1423
+ renderScreenshots?: undefined;
1424
+ requireScreenshots?: undefined;
1425
+ reviewClampLines?: undefined;
1406
1426
  publishUrl?: undefined;
1407
1427
  activityId?: undefined;
1408
1428
  publishedAt?: undefined;
@@ -1424,6 +1444,7 @@ export declare const allTools: ({
1424
1444
  properties: {
1425
1445
  draftId: {
1426
1446
  type: string;
1447
+ description?: undefined;
1427
1448
  };
1428
1449
  publishUrl: {
1429
1450
  type: string;
@@ -1464,6 +1485,12 @@ export declare const allTools: ({
1464
1485
  body?: undefined;
1465
1486
  validationReceipt?: undefined;
1466
1487
  status?: undefined;
1488
+ postText?: undefined;
1489
+ renderId?: undefined;
1490
+ sourceLabel?: undefined;
1491
+ renderScreenshots?: undefined;
1492
+ requireScreenshots?: undefined;
1493
+ reviewClampLines?: undefined;
1467
1494
  updatedAt?: undefined;
1468
1495
  publishedPostId?: undefined;
1469
1496
  year?: undefined;
@@ -1516,6 +1543,12 @@ export declare const allTools: ({
1516
1543
  body?: undefined;
1517
1544
  validationReceipt?: undefined;
1518
1545
  status?: undefined;
1546
+ postText?: undefined;
1547
+ renderId?: undefined;
1548
+ sourceLabel?: undefined;
1549
+ renderScreenshots?: undefined;
1550
+ requireScreenshots?: undefined;
1551
+ reviewClampLines?: undefined;
1519
1552
  updatedAt?: undefined;
1520
1553
  publishUrl?: undefined;
1521
1554
  activityId?: undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.262",
3
+ "version": "0.1.263",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -108,6 +108,7 @@ Use these MCP tools when available:
108
108
  - `mcp__sellable__get_post_idea`
109
109
  - `mcp__sellable__list_post_ideas`
110
110
  - `mcp__sellable__save_hook_research`
111
+ - `mcp__sellable__render_linkedin_post_preview`
111
112
  - `mcp__sellable__save_post_draft`
112
113
  - `mcp__sellable__update_post_draft`
113
114
  - `mcp__sellable__list_post_draft_iterations`
@@ -342,7 +343,8 @@ Visible Flow Trace
342
343
 
343
344
  4. Hook Autopsies
344
345
  - source hook:
345
- - rendered/mobile preview or preview-basis:
346
+ - rendered/mobile preview artifact from `render_linkedin_post_preview` or
347
+ authenticated LinkedIn screenshot:
346
348
  - see-more tension:
347
349
  - curiosity debt:
348
350
  - body promise:
@@ -402,7 +404,7 @@ Visible Flow Trace
402
404
 
403
405
  11. Hook Lab
404
406
  - at least 12 hooks:
405
- - rendered preview record or explicit preview-basis:
407
+ - rendered preview artifact from `render_linkedin_post_preview`:
406
408
  - hook-to-body promise:
407
409
  - score:
408
410
  - selected hook:
@@ -829,17 +831,34 @@ Each hook must include:
829
831
 
830
832
  Do not copy source wording. Copy only the structure.
831
833
 
832
- Use this conservative mobile-first LinkedIn preview gate. LinkedIn does not
833
- publish exact "see more" cutoff rules, and rendering varies by device, app
834
- version, font, media, and line break. These are v1 safety budgets, not claims
835
- about an official LinkedIn limit:
836
-
837
- - `pass`: hook is <= 110 chars including newlines, every nonblank line is <= 45 chars, and the hook's core point lands before likely truncation.
838
- - `warn`: hook is 111-140 chars including newlines, any nonblank line is 46-55 chars, or blank lines create visual-line risk. Blank lines are allowed, but they count as physical lines.
839
- - `fail`: hook is > 140 chars including newlines, any nonblank line is > 55 chars, or the hook's point depends on text after likely truncation.
834
+ Use the rendered-preview contract from
835
+ `references/linkedin-preview-rendering.md`. LinkedIn does not publish exact
836
+ "see more" cutoff rules, and rendering varies by device, app version, font,
837
+ media, and line break. Treat character counts as diagnostics only, not as proof
838
+ that the hook will render before "see more."
839
+
840
+ The selected hook and top candidates must include literal mobile and desktop
841
+ visible blocks. Current third-party preview tools and 2026 creator references
842
+ generally cluster around mobile showing about 2-3 text lines and desktop showing
843
+ about 3-4 text lines before "see more," with media attachments sometimes showing
844
+ less. Blank lines and `--` separators consume visible preview lines.
845
+
846
+ Use:
847
+
848
+ - `pass`: rendered mobile preview shows the pain, proof, or curiosity by the end
849
+ of the first 3 rendered lines, and either the core point is visible or a
850
+ specific intentional open loop is visible with immediate body payoff planned
851
+ - `warn`: rendered mobile preview creates useful curiosity but wrapping,
852
+ blank-line rhythm, media risk, or one missing context word weakens it; include
853
+ a compact fallback
854
+ - `fail`: the hook's real point appears after the rendered mobile review clamp,
855
+ the visible open loop is vague, blank lines consume the preview before the
856
+ point, or desktop fit is the only reason it looks good
840
857
 
841
858
  Desktop preview usually has more room. Still record `desktopPreviewFit`, but
842
- never let desktop fit compensate for a mobile `fail`.
859
+ never let desktop fit compensate for a mobile `fail`. Do not tell the user "we
860
+ know" how LinkedIn will render unless there is an authenticated LinkedIn
861
+ screenshot; say it passes the renderer and show the visible blocks.
843
862
 
844
863
  If a hook's point depends on text after the likely preview, rewrite it before
845
864
  selecting it. A selected hook may carry a `warn` only when the warning is about
@@ -256,19 +256,33 @@ Measure the visible opening for every shortlisted source post before extracting
256
256
  the hook pattern. This makes the study useful for LinkedIn, not just generally
257
257
  "good writing."
258
258
 
259
- LinkedIn does not publish exact "see more" cutoff rules. Treat these as
260
- conservative v1 planning budgets:
261
-
262
- - `pass`: opening hook is <= 110 chars including newlines, every nonblank line
263
- is <= 45 chars, and the hook's core point lands before likely truncation.
264
- - `warn`: opening hook is 111-140 chars including newlines, any nonblank line is
265
- 46-55 chars, or blank lines create visual-line risk. Blank lines are allowed,
266
- but they count as physical lines.
267
- - `fail`: opening hook is > 140 chars including newlines, any nonblank line is
268
- > 55 chars, or the hook's core point depends on text after likely truncation.
259
+ LinkedIn does not publish exact "see more" cutoff rules. Treat preview fit as a
260
+ rendered-line problem first, not a fixed character-count rule. Current
261
+ third-party preview tools and 2026 creator references generally cluster around:
262
+
263
+ - mobile feed: about 2-3 visible text lines, often around 140 characters when
264
+ there is no media
265
+ - desktop feed: about 3-4 visible text lines, often around 210 characters when
266
+ there is no media
267
+ - media posts can show fewer text lines before truncation
268
+ - blank lines and `--` style separators consume visible preview lines
269
+
270
+ Use `references/linkedin-preview-rendering.md` as the required gate. Character
271
+ budgets are diagnostics only:
272
+
273
+ - `pass`: rendered mobile preview shows the pain, proof, or curiosity by the end
274
+ of the first 3 rendered lines, and the hook creates a specific click reason
275
+ - `warn`: rendered mobile preview creates useful curiosity but loses one useful
276
+ context word, wraps awkwardly, or spends a visible line on blank space or a
277
+ separator; include a compact fallback
278
+ - `fail`: the hook's real point appears after the rendered mobile review clamp,
279
+ the visible open loop is vague, blank lines consume the preview before the
280
+ point, or desktop fit is the only reason it looks good
269
281
 
270
282
  Desktop preview has more room, so record it separately, but never let desktop
271
- fit compensate for a mobile `fail`.
283
+ fit compensate for a mobile `fail`. Never say "we know" how LinkedIn will render
284
+ the hook unless there is an authenticated LinkedIn screenshot. Say the hook
285
+ `passes the renderer` and show the mobile/desktop visible blocks.
272
286
 
273
287
  For each source, record:
274
288
 
@@ -282,6 +296,8 @@ For each source, record:
282
296
  - `firstTwoContentLinesChars`
283
297
  - `longestNonblankLineChars`
284
298
  - `blankLineCountBeforeFold`
299
+ - `renderedPreview`: literal mobile and desktop visible blocks from
300
+ `references/linkedin-preview-rendering.md`
285
301
  - `mobilePreviewBudget`: `pass`, `warn`, or `fail`
286
302
  - `desktopPreviewBudget`: `pass`, `warn`, or `fail`
287
303
  - `blankLineVisualRisk`
@@ -5,8 +5,14 @@ research, hook candidate generation, gold-standard decomposition, and draft
5
5
  validation.
6
6
 
7
7
  Character budgets are only diagnostics. They are not the preview gate. A hook is
8
- not studied, selected, or ready until it has been rendered through this contract
9
- or through a stricter authenticated LinkedIn screenshot.
8
+ not studied, selected, or ready until it has been rendered through
9
+ `mcp__sellable__render_linkedin_post_preview` or through a stricter
10
+ authenticated LinkedIn screenshot.
11
+
12
+ Do not let the LLM imagine wrapping. Character counts and hand-written line
13
+ blocks are only fallback diagnostics. The normal path is tool-rendered mobile
14
+ and desktop artifacts: HTML always, and PNG screenshots whenever local Chrome is
15
+ available.
10
16
 
11
17
  ## Rendering Basis
12
18
 
@@ -14,6 +20,38 @@ LinkedIn does not publish exact feed truncation rules, and rendering can vary by
14
20
  surface, app version, device, media attachment, and account state. Use this
15
21
  deterministic renderer as the MCP review gate for unpublished drafts.
16
22
 
23
+ Call:
24
+
25
+ ```json
26
+ mcp__sellable__render_linkedin_post_preview({
27
+ "postText": "<full post text or candidate hook block>",
28
+ "sourceLabel": "<draft id, hook id, or source label>",
29
+ "renderScreenshots": true
30
+ })
31
+ ```
32
+
33
+ When screenshots are produced, the PNG files are the review artifact. When
34
+ screenshots cannot be produced, the HTML artifact is still deterministic and
35
+ must be opened or explicitly marked as lower confidence.
36
+
37
+ Treat "see more" as a rendered-line problem first and a character-count problem
38
+ second. Current third-party preview tools and 2026 creator references generally
39
+ cluster around these working assumptions:
40
+
41
+ - mobile feed: about 2-3 visible text lines, often around 140 characters when
42
+ there is no media
43
+ - desktop feed: about 3-4 visible text lines, often around 210 characters when
44
+ there is no media
45
+ - posts with media may show fewer text lines before truncation, sometimes only
46
+ 1-2 lines
47
+ - blank lines and intentional separators consume visible preview lines
48
+ - device width, font scaling, app version, browser, and profile/page context can
49
+ move the cutoff
50
+
51
+ Because of that variation, never say a hook is guaranteed to render before
52
+ "see more." Say `pass`, `warn`, or `fail` under the rendered-preview contract,
53
+ and show the visible mobile and desktop blocks.
54
+
17
55
  When an authenticated LinkedIn feed/composer/browser screenshot is available,
18
56
  that screenshot is the strongest evidence. Still record the fields below so
19
57
  future agents can compare candidates without redoing the visual inspection.
@@ -48,13 +86,13 @@ selected hook must include a `renderedPreview` record:
48
86
 
49
87
  ```text
50
88
  renderedPreview:
51
- basis: linkedin_css_contract | authenticated_linkedin_screenshot | manual_user_source
89
+ basis: local_chrome_headless_screenshot | linkedin_css_contract_html | authenticated_linkedin_screenshot | manual_user_source
52
90
  cssContractVersion: linkedin-preview-rendering/v1
53
91
  mobile:
54
92
  textWidthPx: 308
55
93
  fontSizePx: 14
56
94
  lineHeightPx: 21
57
- visibleTextBlock: <literal first rendered review-clamp block>
95
+ visibleTextBlock: <literal first rendered review-clamp block or tool estimated block when screenshot is the authority>
58
96
  renderedLines:
59
97
  - <line 1 exactly as wrapped>
60
98
  - <line 2 exactly as wrapped>
@@ -68,7 +106,8 @@ renderedPreview:
68
106
  payoffPlannedImmediatelyAfterClamp: true | false
69
107
  seeMoreClickReason: <why a reader would click see more>
70
108
  seeMoreRisk: pass | warn | fail
71
- screenshotPath: <optional local path>
109
+ htmlPath: <local path>
110
+ screenshotPath: <local path when Chrome rendered PNG>
72
111
  desktop:
73
112
  textWidthPx: 582
74
113
  fontSizePx: 14
@@ -83,7 +122,8 @@ renderedPreview:
83
122
  corePainProofOrCuriosityVisible: true | false
84
123
  corePointVisible: true | false
85
124
  seeMoreRisk: pass | warn | fail
86
- screenshotPath: <optional local path>
125
+ htmlPath: <local path>
126
+ screenshotPath: <local path when Chrome rendered PNG>
87
127
  diagnostics:
88
128
  charCount: <number>
89
129
  charCountIncludingNewlines: <number>
@@ -96,10 +136,11 @@ renderedPreview:
96
136
  rewriteIfTruncated: <short fallback>
97
137
  ```
98
138
 
99
- If a host cannot produce screenshots, it must still produce the literal wrapped
100
- line blocks using the CSS contract. If it cannot produce either screenshots or
101
- literal line wraps, return `blocked` or `needs_revision`; do not claim the hook
102
- passed preview validation from character counts alone.
139
+ If a host cannot produce screenshots, it must still produce and preserve the
140
+ HTML artifacts from `render_linkedin_post_preview`. If it cannot produce
141
+ screenshots, HTML artifacts, or literal wrapped line blocks, return `blocked` or
142
+ `needs_revision`; do not claim the hook passed preview validation from character
143
+ counts alone.
103
144
 
104
145
  ## Study Rules
105
146
 
@@ -320,22 +320,38 @@ proof from another creator, save as `needs_revision`.
320
320
 
321
321
  ## LinkedIn Preview Audit
322
322
 
323
- Audit the selected hook and top candidates against conservative LinkedIn
324
- preview budgets. LinkedIn does not publish exact "see more" cutoff rules, and
325
- rendering varies by device, app version, font, media, and line break. This audit
326
- is a mobile-first safety gate, not a claim about an official LinkedIn limit.
323
+ Audit the selected hook and top candidates against the rendered-preview
324
+ contract. LinkedIn does not publish exact "see more" cutoff rules, and rendering
325
+ varies by device, app version, font, media, line break, and whether the post has
326
+ media attached. This audit is a mobile-first safety gate, not a claim about an
327
+ official LinkedIn limit.
328
+
329
+ Treat character counts as diagnostics only. The gate is the literal rendered
330
+ mobile and desktop visible block from `references/linkedin-preview-rendering.md`
331
+ or an authenticated LinkedIn screenshot.
327
332
 
328
333
  Use:
329
334
 
330
- - `pass`: hook is <= 110 chars including newlines, every nonblank line is <= 45 chars, and the hook's core point lands before likely truncation.
331
- - `warn`: hook is 111-140 chars including newlines, any nonblank line is 46-55 chars, or blank lines create visual-line risk. Blank lines are allowed, but they count as physical lines.
332
- - `fail`: hook is > 140 chars including newlines, any nonblank line is > 55 chars, or the hook's point depends on text after likely truncation.
335
+ - `pass`: the rendered mobile preview shows the pain, proof, or curiosity by the
336
+ end of the first 3 rendered lines, and either the core point is visible or a
337
+ specific intentional open loop is visible with immediate body payoff planned
338
+ - `warn`: the rendered mobile preview creates useful curiosity but wrapping,
339
+ blank-line rhythm, media risk, or one missing context word weakens it; include
340
+ a compact fallback
341
+ - `fail`: the hook's real point appears after the rendered mobile review clamp,
342
+ the visible open loop is vague, blank lines consume the preview before the
343
+ point, or desktop fit is the only reason it looks good
333
344
 
334
345
  Desktop preview usually has more room. Still record desktop fit, but never let
335
346
  desktop fit compensate for a mobile `fail`.
336
347
 
337
348
  Record:
338
349
 
350
+ - `renderedPreview`
351
+ - `renderedPreviewBasis`: `linkedin_css_contract` |
352
+ `authenticated_linkedin_screenshot` | `manual_user_source`
353
+ - literal mobile visible block
354
+ - literal desktop visible block
339
355
  - `charCount`
340
356
  - `charCountIncludingNewlines`
341
357
  - `firstLineChars`
@@ -355,9 +371,11 @@ Record:
355
371
  - `compactFallback` when `previewBudgetStatus` is `warn`
356
372
 
357
373
  If the hook only works after likely truncation, rewrite it. A draft cannot be
358
- `ready` with `previewBudgetStatus: fail`. A draft may be `ready` with
374
+ `ready` with `previewBudgetStatus: fail`, with no rendered preview block, or
375
+ with only a character-count claim. A draft may be `ready` with
359
376
  `previewBudgetStatus: warn` only when the warning is explicit, usually because
360
- the user prefers blank-line rhythm, and the receipt includes a compact fallback.
377
+ the user prefers blank-line rhythm, separators, or a deliberate open loop, and
378
+ the receipt includes a compact fallback.
361
379
 
362
380
  ## Simplifier / Concrete-Language Audit
363
381