linkedin_publish
Publish original LinkedIn posts with a preview-first workflow. Dry run returns formatted text, resolved mentions, and media validation. After user confirms, call again with dry_run=false to publish.
Instructions
Publish an original post to LinkedIn via Unipile. IMPORTANT: dry_run defaults to true — this returns a preview showing the formatted text, resolved mentions, validated media, and character count. Review the preview carefully, then call again with dry_run=false to actually publish. Supports text (max 3000 chars), media attachments (local file paths or URLs to images/videos: jpg, png, gif, webp, mp4), and company @mentions (pass company names — they are resolved automatically via Unipile and injected as {{0}}, {{1}} placeholders). WORKFLOW: 1) Call with dry_run=true, 2) Present preview to user, 3) Get confirmation, 4) Call with dry_run=false.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| text | Yes | Post body text. Maximum 3000 characters. Include company names here if you want them @mentioned — they will be replaced with Unipile placeholders automatically. | |
| account_id | No | Optional. Unipile account ID to post from. If omitted, the first LinkedIn account found in Unipile is used (default behavior). | |
| media | No | Optional. Array of local file paths or URLs to attach. Supported formats: jpg, png, gif, webp, mp4. URLs are downloaded to /tmp automatically. | |
| mentions | No | Optional. Array of company names to @mention (e.g. ["Microsoft", "OpenAI"]). Each name is resolved to a LinkedIn company ID via Unipile. | |
| dry_run | No | DEFAULT TRUE. When true, returns a preview without publishing. Set to false only after user confirms the preview. |
Implementation Reference
- src/tools/publish.js:40-200 (handler)Main handler function for the linkedin_publish tool. Orchestrates the entire publish flow: normalizes arguments, resolves account/mentions, processes media, handles dry-run preview, and executes the actual publish via createPost().
export async function handlePublish(args) { const { text, account_id } = args; // Normalize media: Claude Code may pass array as JSON string "[...]" let media = args.media ?? []; if (typeof media === "string") { try { media = JSON.parse(media); if (!Array.isArray(media)) media = [media]; } catch { media = media.length > 0 ? [media] : []; } } if (!Array.isArray(media)) media = []; // Normalize mentions similarly let mentions = args.mentions ?? []; if (typeof mentions === "string") { try { mentions = JSON.parse(mentions); if (!Array.isArray(mentions)) mentions = [mentions]; } catch { mentions = mentions.length > 0 ? [mentions] : []; } } if (!Array.isArray(mentions)) mentions = []; // Normalize dry_run: string "false" is truthy in JS — handle explicitly const dry_run = args.dry_run === false || args.dry_run === "false" ? false : true; // ── Validate text ──────────────────────────────────────────────────────── if (!text || typeof text !== "string" || text.trim().length === 0) { return { error: "text is required and must be a non-empty string" }; } const warnings = []; // ── Resolve account ────────────────────────────────────────────────────── let accountId; if (account_id) { accountId = account_id; } else { const accountResult = await resolveAccountId(); if (!accountResult.success) { return { error: `Could not resolve LinkedIn account: ${accountResult.error}`, }; } accountId = accountResult.data; } // ── Resolve company mentions ───────────────────────────────────────────── const resolvedMentions = []; for (const companyName of mentions) { const result = await resolveCompanyId(companyName); if (result.success) { resolvedMentions.push(result.data); } else { warnings.push(`Mention not resolved: "${companyName}" — ${result.error}`); } } // ── Inject placeholders into text ──────────────────────────────────────── const finalText = injectMentionPlaceholders(text, resolvedMentions); // ── Character count check ──────────────────────────────────────────────── if (finalText.length > CHAR_LIMIT) { warnings.push( `Post exceeds ${CHAR_LIMIT} character limit (${finalText.length} chars). LinkedIn will reject it.`, ); } // ── Process media ──────────────────────────────────────────────────────── let mediaResults = []; let mediaFiles = []; if (media.length > 0) { const { resolved, failed } = await processMedia(media); mediaFiles = resolved; mediaResults = [ ...resolved.map((m) => ({ source: m.source, valid: true, type: m.mimeType, size_kb: Math.round(m.sizeBytes / 1024), })), ...failed.map((f) => ({ source: f.source, valid: false, error: f.error, })), ]; if (failed.length > 0) { warnings.push( `${failed.length} media item(s) could not be processed and will be skipped.`, ); } } // ── Dry run — return preview ───────────────────────────────────────────── if (dry_run) { cleanupTmpMedia(); // Clean up downloads since we won't publish return { status: "preview", post_text: finalText, character_count: finalText.length, character_limit: CHAR_LIMIT, media: mediaResults, mentions: mentions.map((name, i) => { const resolved = resolvedMentions[i]; return resolved ? { name: resolved.name, resolved: true, profile_id: resolved.profileId, } : { name, resolved: false }; }), warnings, ready_to_publish: warnings.length === 0 || !warnings.some((w) => w.includes("exceeds")), }; } // ── Publish ────────────────────────────────────────────────────────────── try { const result = await createPost( accountId, finalText, mediaFiles, resolvedMentions, ); if (!result.success) { return { error: result.error, details: result.details }; } const postId = result.data.postId; let autoLike; if (postId) { const urn = `urn:li:activity:${postId}`; const likeResult = await reactToPost(accountId, urn, "like"); autoLike = likeResult.success ? "liked" : `failed: ${likeResult.error}`; } else { autoLike = "skipped: no post_id returned"; } return { status: "published", post_id: postId, post_text: finalText, posted_at: result.data.postedAt, auto_like: autoLike, }; } finally { cleanupTmpMedia(); } } - src/server.js:30-65 (schema)Input schema for the linkedin_publish tool. Defines properties: text (required), account_id (optional), media (optional array of strings), mentions (optional array of strings), dry_run (optional boolean, defaults true).
inputSchema: { type: "object", properties: { text: { type: "string", description: "Post body text. Maximum 3000 characters. Include company names here if you want them @mentioned — they will be replaced with Unipile placeholders automatically.", }, account_id: { type: "string", description: "Optional. Unipile account ID to post from. If omitted, the first LinkedIn account found in Unipile is used (default behavior).", }, media: { type: "array", items: { type: "string" }, description: "Optional. Array of local file paths or URLs to attach. Supported formats: jpg, png, gif, webp, mp4. URLs are downloaded to /tmp automatically.", default: [], }, mentions: { type: "array", items: { type: "string" }, description: 'Optional. Array of company names to @mention (e.g. ["Microsoft", "OpenAI"]). Each name is resolved to a LinkedIn company ID via Unipile.', default: [], }, dry_run: { type: "boolean", description: "DEFAULT TRUE. When true, returns a preview without publishing. Set to false only after user confirms the preview.", default: true, }, }, required: ["text"], }, - src/server.js:17-66 (registration)Tool definition registration for linkedin_publish. Includes name, description with workflow instructions, and inputSchema. Listed in the TOOLS array returned by ListToolsRequestSchema.
const TOOLS = [ { name: "linkedin_publish", description: "Publish an original post to LinkedIn via Unipile. " + "IMPORTANT: dry_run defaults to true — this returns a preview showing the formatted text, " + "resolved mentions, validated media, and character count. Review the preview carefully, " + "then call again with dry_run=false to actually publish. " + "Supports text (max 3000 chars), media attachments (local file paths or URLs to images/videos: " + "jpg, png, gif, webp, mp4), and company @mentions (pass company names — they are resolved " + "automatically via Unipile and injected as {{0}}, {{1}} placeholders). " + "WORKFLOW: 1) Call with dry_run=true, 2) Present preview to user, 3) Get confirmation, " + "4) Call with dry_run=false.", inputSchema: { type: "object", properties: { text: { type: "string", description: "Post body text. Maximum 3000 characters. Include company names here if you want them @mentioned — they will be replaced with Unipile placeholders automatically.", }, account_id: { type: "string", description: "Optional. Unipile account ID to post from. If omitted, the first LinkedIn account found in Unipile is used (default behavior).", }, media: { type: "array", items: { type: "string" }, description: "Optional. Array of local file paths or URLs to attach. Supported formats: jpg, png, gif, webp, mp4. URLs are downloaded to /tmp automatically.", default: [], }, mentions: { type: "array", items: { type: "string" }, description: 'Optional. Array of company names to @mention (e.g. ["Microsoft", "OpenAI"]). Each name is resolved to a LinkedIn company ID via Unipile.', default: [], }, dry_run: { type: "boolean", description: "DEFAULT TRUE. When true, returns a preview without publishing. Set to false only after user confirms the preview.", default: true, }, }, required: ["text"], }, }, - src/server.js:147-149 (registration)Tool call dispatch for linkedin_publish in the CallToolRequestSchema handler. Routes the 'linkedin_publish' case to handlePublish() from ./tools/publish.js.
case "linkedin_publish": result = await handlePublish(args || {}); break; - src/tools/publish.js:22-38 (helper)Helper function injectMentionPlaceholders - replaces company names in post text with {{0}}, {{1}} placeholders for Unipile mention injection.
export function injectMentionPlaceholders(text, resolvedMentions) { let result = text; for (let i = 0; i < resolvedMentions.length; i++) { const name = resolvedMentions[i].name; const placeholder = `{{${i}}}`; const nameRegex = new RegExp( `(?<![\\w@])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w])`, "gi", ); if (nameRegex.test(result)) { result = result.replace(nameRegex, placeholder); } else { result += ` ${placeholder}`; } } return result; }