Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion skills/typefully/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ All notable user-facing changes to the Typefully skill and its CLI are documente

The format is based on Keep a Changelog.

## 2026-07-09
## 2026-07-10

### Added

- LinkedIn first comment support: `drafts:create`/`drafts:update` accept `--linkedin-first-comment <text>` to post a plain-text comment right after the LinkedIn post is published (the "link in the first comment" pattern). On update, a literal `null` removes the comment and omitting the flag preserves it.
- Aliases: `--linkedin_first_comment`, `--first-comment`, `--first_comment`.
- Documented in `SKILL.md` (flags table, common actions) and `references/platforms/linkedin.md`.

### Changed

Expand Down
6 changes: 4 additions & 2 deletions skills/typefully/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: >
social media content for Twitter/X, LinkedIn, Threads, Bluesky, or Mastodon,
or when the user drops a Typefully draft URL such as
https://typefully.com/?a=<social_set_id>&d=<draft_id>.
last-updated: 2026-07-09
last-updated: 2026-07-10
allowed-tools: Bash(./scripts/typefully.js:*)
---

Expand Down Expand Up @@ -135,6 +135,7 @@ When the user asks to add notes, ideas, or context to a draft, use `--scratchpad
| "Check my publishing quota" | `social-sets:get` → `publishing_quota` |
| "Draft an X Article" | See [`references/platforms/x-articles.md`](references/platforms/x-articles.md) |
| "Mention a company on LinkedIn" | See [`references/platforms/linkedin.md`](references/platforms/linkedin.md) |
| "Put the link in a LinkedIn first comment" | `drafts:create --platform linkedin --text "..." --linkedin-first-comment "..."` |
| "Show my X analytics / followers" | See [`references/platforms/x.md`](references/platforms/x.md) |
| "Comment on / resolve a comment" | See [`references/comments.md`](references/comments.md) |

Expand Down Expand Up @@ -183,6 +184,7 @@ Add any of these flags to a `drafts:create` or `drafts:update` command. The **Ap
| `--scratchpad "<notes>"` | Attach internal notes (see [Scratchpad notes](#scratchpad-notes)) | create, update |
| `--share` | Generate a public share URL | create, update |
| `--schedule <iso\|next-free-slot\|now>` | Schedule or reschedule the draft | create, update |
| `--linkedin-first-comment "<text>"` | LinkedIn first comment, posted right after publishing (`null` removes it on update) | create, update |
| `--exclude-comment-markers` | Render response without anchors (display only; validation still applies) | update |
| `--force-overwrite-comments` | Destructive last resort — see [`comments.md`](references/comments.md) | update |

Expand All @@ -193,7 +195,7 @@ For example, combine the base command with flags like this:
./scripts/typefully.js drafts:update 456 --text "Revised copy" --media abc-123 --use-default
```

> X-only draft flags (`--reply-to`, `--quote-post-url`, `--community`, `--paid-partnership`, `--made-with-ai`): see [`platforms/x.md`](references/platforms/x.md). X Article flags (`--content-markdown`, `--cover-media-id`): see [`platforms/x-articles.md`](references/platforms/x-articles.md).
> X-only draft flags (`--reply-to`, `--quote-post-url`, `--community`, `--paid-partnership`, `--made-with-ai`): see [`platforms/x.md`](references/platforms/x.md). LinkedIn-only flags (`--linkedin-first-comment`): see [`platforms/linkedin.md`](references/platforms/linkedin.md). X Article flags (`--content-markdown`, `--cover-media-id`): see [`platforms/x-articles.md`](references/platforms/x-articles.md).

### Scheduling & publishing

Expand Down
19 changes: 19 additions & 0 deletions skills/typefully/references/platforms/linkedin.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,22 @@ Then include the returned `mention_text` in the draft:
`linkedin:organizations:resolve [social_set_id] --organization-url <url>` returns `mention_text` and `urn`.

> When commenting on text that contains a mention, select the entire `@[Name](urn:li:...)` substring or stay fully outside it.

## First comment

Post a comment right after the LinkedIn post is published — the common "link in the first comment" pattern:

```bash
./scripts/typefully.js drafts:create --platform linkedin --text "Big launch today!" --linkedin-first-comment "Full details: https://example.com/launch"
```

On `drafts:update`, the flag sets or replaces the comment; a literal `null` removes it; omitting the flag keeps the current comment:

```bash
./scripts/typefully.js drafts:update 123 d456 --linkedin-first-comment "Link here: https://example.com"
./scripts/typefully.js drafts:update 123 d456 --linkedin-first-comment null
```

- Plain text only: newlines are preserved and LinkedIn renders URLs as links, but mention syntax is **not** supported inside comments.
- Aliases: `--linkedin_first_comment`, `--first-comment`, `--first_comment`.
- Draft responses expose it as `platforms.linkedin.settings.first_comment`.
112 changes: 101 additions & 11 deletions skills/typefully/scripts/typefully.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,34 @@ function addQuotePostUrl(posts, quotePostUrl) {
return posts.map(post => ({ ...post, quote_post_url: quotePostUrl }));
}

// LinkedIn first comment: a plain-text comment Typefully posts right after the
// LinkedIn post is published. Returns { provided, value } where value === null
// means "clear the first comment" (literal null, same convention as
// --cover-media-id).
function getLinkedInFirstCommentFromParsed(parsed) {
const keys = ['linkedin-first-comment', 'linkedin_first_comment', 'first-comment', 'first_comment'];
const found = [];
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(parsed, key)) {
found.push({ key, value: coerceFlagValueToString(parsed[key], `--${key}`, { allowEmpty: true }) });
}
}

if (found.length === 0) return { provided: false, value: null };

const primary = found[0].value;
for (const entry of found) {
if (entry.value !== primary) {
error('Conflicting LinkedIn first comment values', Object.fromEntries(found.map(e => [`--${e.key}`, e.value])));
}
}

if (primary.trim() === '' || primary.trim().toLowerCase() === 'null') {
return { provided: true, value: null };
}
return { provided: true, value: primary };
}

// Draft GET responses include response-only and platform-specific post fields
// (e.g. subscribers_only, linkedin_reshare_urn) that the API's request schemas
// reject with 422 on other platforms. Before re-sending fetched posts, keep only
Expand Down Expand Up @@ -512,6 +540,12 @@ function validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures }) {
}
}

function validateLinkedInOnlyOptions(platformList, { firstComment }) {
if (firstComment.provided && !platformList.includes('linkedin')) {
error('--linkedin-first-comment is only supported for LinkedIn. Include linkedin in --platform or remove the flag.');
}
}

function hasParsedArg(parsed, key) {
return Object.prototype.hasOwnProperty.call(parsed, key);
}
Expand Down Expand Up @@ -1264,6 +1298,7 @@ async function cmdDraftsCreate(args) {
const socialSetId = resolveSocialSetIdFromParsed(parsed, parsed._positional[0]);
const quotePostUrl = getQuotePostUrlFromParsed(parsed);
const xContentDisclosures = getXContentDisclosuresFromParsed(parsed);
const linkedInFirstComment = getLinkedInFirstCommentFromParsed(parsed);

// Determine platform(s)
let platforms = parsed.platform;
Expand Down Expand Up @@ -1294,6 +1329,7 @@ async function cmdDraftsCreate(args) {

const platformList = parsePlatformList(platforms);
validateXArticlePlatformUsage(platformList, parsed);
validateLinkedInOnlyOptions(platformList, { firstComment: linkedInFirstComment });

// Build request body
const platformsObj = {};
Expand Down Expand Up @@ -1349,6 +1385,11 @@ async function cmdDraftsCreate(args) {
}
}

// LinkedIn-specific settings
if (platform === 'linkedin' && linkedInFirstComment.value) {
platformConfig.settings = { first_comment: linkedInFirstComment.value };
}

platformsObj[platform] = platformConfig;
}
}
Expand Down Expand Up @@ -1396,6 +1437,7 @@ async function cmdDraftsUpdate(args) {
const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:update');
const quotePostUrl = getQuotePostUrlFromParsed(parsed);
const xContentDisclosures = getXContentDisclosuresFromParsed(parsed);
const linkedInFirstComment = getLinkedInFirstCommentFromParsed(parsed);

const body = {};
const explicitPlatformList = parsed.platform
Expand All @@ -1408,6 +1450,7 @@ async function cmdDraftsUpdate(args) {

if (explicitPlatformList) {
validateXArticlePlatformUsage(explicitPlatformList, parsed);
validateLinkedInOnlyOptions(explicitPlatformList, { firstComment: linkedInFirstComment });
}

const shouldUpdateArticle = Boolean(
Expand All @@ -1428,7 +1471,9 @@ async function cmdDraftsUpdate(args) {
text = getPostTextFromParsed(parsed);
}

const shouldUpdatePosts = Boolean(!shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny));
const shouldUpdatePosts = Boolean(
!shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny || linkedInFirstComment.provided)
);
if (shouldUpdatePosts) {
if (explicitPlatformList) {
validateXOnlyPostOptions(explicitPlatformList, {
Expand Down Expand Up @@ -1469,8 +1514,10 @@ async function cmdDraftsUpdate(args) {
quotePostUrl,
disclosures: xContentDisclosures,
});
validateLinkedInOnlyOptions(platformList, { firstComment: linkedInFirstComment });

let postsArray;
let metadataOnlyPosts = null; // platform -> existing posts, for metadata-only updates

if (text) {
if (parsed.append) {
Expand Down Expand Up @@ -1501,29 +1548,49 @@ async function cmdDraftsUpdate(args) {
});
}
} else {
// X-only metadata update: preserve existing X posts and add quote/disclosure attrs.
const existingXPosts = existing.platforms?.x?.posts;
if (!Array.isArray(existingXPosts) || existingXPosts.length === 0) {
if (quotePostUrl && !xContentDisclosures.hasAny) {
error('Cannot apply --quote-post-url because this draft has no existing X posts');
// Metadata-only update: preserve existing posts on the target platforms.
metadataOnlyPosts = {};
platformList = [];

if (quotePostUrl || xContentDisclosures.hasAny) {
const existingXPosts = existing.platforms?.x?.posts;
if (!Array.isArray(existingXPosts) || existingXPosts.length === 0) {
if (quotePostUrl && !xContentDisclosures.hasAny) {
error('Cannot apply --quote-post-url because this draft has no existing X posts');
}
error('Cannot apply X-only post options because this draft has no existing X posts');
}
metadataOnlyPosts.x = existingXPosts;
platformList.push('x');
}

if (linkedInFirstComment.provided) {
const existingLinkedInPosts = existing.platforms?.linkedin?.posts;
if (!Array.isArray(existingLinkedInPosts) || existingLinkedInPosts.length === 0) {
error('Cannot apply --linkedin-first-comment because this draft has no existing LinkedIn posts');
}
error('Cannot apply X-only post options because this draft has no existing X posts');
metadataOnlyPosts.linkedin = existingLinkedInPosts;
platformList.push('linkedin');
}
postsArray = existingXPosts;
platformList = ['x'];
}

// Build platforms object
const platformsObj = {};
for (const p of platformList) {
const sanitizedPosts = postsArray.map(post => sanitizePostForPlatform(post, p));
const sourcePosts = metadataOnlyPosts ? metadataOnlyPosts[p] : postsArray;
const sanitizedPosts = sourcePosts.map(post => sanitizePostForPlatform(post, p));
const platformPosts = p === 'x'
? addXContentDisclosures(addQuotePostUrl(sanitizedPosts, quotePostUrl), xContentDisclosures)
: sanitizedPosts;
platformsObj[p] = {
enabled: true,
posts: platformPosts,
};
// The API preserves the LinkedIn first comment when settings are
// omitted, so only send them when the flag was passed (null clears).
if (p === 'linkedin' && linkedInFirstComment.provided) {
platformsObj[p].settings = { first_comment: linkedInFirstComment.value };
}
}
body.platforms = platformsObj;
}
Expand Down Expand Up @@ -1553,7 +1620,7 @@ async function cmdDraftsUpdate(args) {
}

if (Object.keys(body).length === 0) {
error('At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, or --force-overwrite-comments is required');
error('At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --linkedin-first-comment, --paid-partnership, --made-with-ai, or --force-overwrite-comments is required');
}

const params = new URLSearchParams();
Expand Down Expand Up @@ -1620,6 +1687,10 @@ async function cmdCreateDraftAlias(args) {
pushStringFlag(forwarded, parsed, 'community', '--community');
const quotePostUrl = getQuotePostUrlFromParsed(parsed);
if (quotePostUrl) forwarded.push('--quote-post-url', quotePostUrl);
const linkedInFirstComment = getLinkedInFirstCommentFromParsed(parsed);
if (linkedInFirstComment.provided) {
forwarded.push('--linkedin-first-comment', linkedInFirstComment.value ?? 'null');
}
if (parsed['paid-partnership'] || parsed.paid_partnership) forwarded.push('--paid-partnership');
if (parsed['made-with-ai'] || parsed.made_with_ai) forwarded.push('--made-with-ai');
if (parsed.share) forwarded.push('--share');
Expand Down Expand Up @@ -1668,6 +1739,10 @@ async function cmdUpdateDraftAlias(args) {
pushStringFlag(forwarded, parsed, 'tags', '--tags', { allowEmpty: true });
const quotePostUrl = getQuotePostUrlFromParsed(parsed);
if (quotePostUrl) forwarded.push('--quote-post-url', quotePostUrl);
const linkedInFirstComment = getLinkedInFirstCommentFromParsed(parsed);
if (linkedInFirstComment.provided) {
forwarded.push('--linkedin-first-comment', linkedInFirstComment.value ?? 'null');
}
if (parsed['paid-partnership'] || parsed.paid_partnership) forwarded.push('--paid-partnership');
if (parsed['made-with-ai'] || parsed.made_with_ai) forwarded.push('--made-with-ai');
if (parsed.share) forwarded.push('--share');
Expand Down Expand Up @@ -2152,6 +2227,9 @@ COMMANDS:
--reply-to <url> URL of X post to reply to
--community <id> X community ID to post to
--quote-post-url, --quote-url <url> Quote an X post URL (X only)
--linkedin-first-comment <text> Plain-text comment posted right after the LinkedIn
post is published (LinkedIn only, no mention syntax).
Also accepts: --first-comment
--paid-partnership, --paid_partnership Label X posts as paid partnership
--made-with-ai, --made_with_ai Label X posts as made with AI
--share Generate a public share URL for the draft
Expand All @@ -2170,6 +2248,9 @@ COMMANDS:
--schedule <time> "now", "next-free-slot", or ISO datetime
--tags <tag_slugs> Comma-separated tag slugs
--quote-post-url, --quote-url <url> Quote an X post URL (X only)
--linkedin-first-comment <text|null> Set the LinkedIn first comment; use literal null to
remove it. Omitting the flag keeps the current comment.
Also accepts: --first-comment
--paid-partnership, --paid_partnership Label X posts as paid partnership
--made-with-ai, --made_with_ai Label X posts as made with AI
--share Generate a public share URL for the draft
Expand Down Expand Up @@ -2297,6 +2378,15 @@ EXAMPLES:
# Use resolved mention syntax in a LinkedIn draft
./typefully.js drafts:create 123 --platform linkedin --text "Thanks @[Typefully](urn:li:organization:86779668) for the support."

# Create a LinkedIn post with a first comment (posted right after publishing)
./typefully.js drafts:create 123 --platform linkedin --text "Big launch today!" --linkedin-first-comment "Full details: https://example.com/launch"

# Add or replace the first comment on an existing LinkedIn draft
./typefully.js drafts:update 123 d456 --linkedin-first-comment "Link in this comment: https://example.com"

# Remove the first comment from a LinkedIn draft
./typefully.js drafts:update 123 d456 --linkedin-first-comment null

# Create a tweet (uses default social set if configured)
./typefully.js drafts:create --text "Hello world!"

Expand Down
Loading
Loading