Skip to main content
Glama

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

TableJSON Schema
NameRequiredDescriptionDefault
textYesPost body text. Maximum 3000 characters. Include company names here if you want them @mentioned — they will be replaced with Unipile placeholders automatically.
mediaNoOptional. Array of local file paths or URLs to attach. Supported formats: jpg, png, gif, webp, mp4. URLs are downloaded to /tmp automatically.
mentionsNoOptional. Array of company names to @mention (e.g. ["Microsoft", "OpenAI"]). Each name is resolved to a LinkedIn company ID via Unipile.
dry_runNoDEFAULT TRUE. When true, returns a preview without publishing. Set to false only after user confirms the preview.

Implementation Reference

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

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/timkulbaev/mcp-linkedin'

If you have feedback or need assistance with the MCP directory API, please join our Discord server