linkedin_publish
Publish posts to LinkedIn with preview functionality. Create content with text, media attachments, and company mentions, then review before publishing.
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. | |
| 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-167 (handler)The `handlePublish` function in `src/tools/publish.js` is the main handler for the `linkedin_publish` tool. It processes input text, resolves LinkedIn account and company mentions, prepares media, and either previews the post (if `dry_run` is true) or publishes it via the `createPost` function.
export async function handlePublish(args) { const { text, media = [], mentions = [], dry_run = true } = args; // ── 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 ────────────────────────────────────────────────────── const accountResult = await resolveAccountId(); if (!accountResult.success) { return { error: `Could not resolve LinkedIn account: ${accountResult.error}`, }; } const 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(); } }