CreateLinkedInPost() in state/lib/linkedin.ts (POST… .md file to compare - side-by-side diff against linkedin-post
linkedin-post
description: "Triggers on prompt mention of 'linkedin-post', 'publish to linkedin', or '/run-skill linkedin-post'."
What it does for you
Publishes a post to LinkedIn after it passes your voice check.
What it produces
A recent result, so you can see the kind of work it returns.
loading…
How to get it
These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
what's inside - the parts that make up a skill 2/4 present
A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.
state/skills/linkedin-post/SKILL.md
present
state/lib/linkedin-post.ts
not present
state/bin/linkedin-post/
not present
state/skills/linkedin-post/AGENTS.md
present
how it's graded - what counts as a good run 6 criteria · 6 deterministic
Each row is one thing a good run has to get right. deterministic means a quick check decides, pass or fail. judge means the AI reads the result and rates it. Grading each piece on its own (instead of one overall score) shows exactly where a run fell short, so the fix is obvious.
how it runs - the shared frame every skill uses 4/5 present
Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.
Linkedin("GET", "/rest/posts/<urn>") in the same This skill doesn't fix its own gaps yet.
state/log/evals.ndjson - ALWAYS run voice.checkTone() from state/lib/voice.ts BEFORE calling createLinkedInPost — em-dashes, hype words, AI tells HARD-BLOCK
- NEVER call createLinkedInPost more than once per run_id — a duplicate post on LinkedIn cannot be cleanly undone (delete leaves a gap)
- ALWAYS capture result.headers["x-restli-id"] — that URN is the only handle for the GET-back verification (memory: actor-≠-auditor)
- The auditor MUST be a separate read path: linkedin("GET", "/rest/posts/<urn>") — trusting the 201 alone is the same trap content-polish was built to catch
- Use dry_run: true for any UI-driven preview; real dry_run: false only behind an explicit operator click
- NEVER ship a post that wasn't drafted through content-polish first — the voice gate here is a defense, not the primary check
- +1 more in AGENTS.md →
what it has learned - fixes written back in over time sample
When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.
- Loading feedback rows…
how the work flows- who makes it, who checks it
prose skill — follow steps in `state/skills/linkedin-post/SKILL.md
what this step does
SKILL.md- the skill, written out in plain English
linkedin-post
The first one-click publish surface. Takes a draft, gates it on voice, fires the post to LinkedIn's REST API, then re-fetches the returned URN to prove it landed. Actor (POST writer) and auditor (URN reader) share the same API but are separate verbs - createLinkedInPost() versus linkedin("GET", ...).
This skill exists because state/lib/linkedin.ts has had a battle-tested createLinkedInPost() since the kernel days, but no skill wrapped it. PiD could draft LinkedIn copy all day and never click publish without leaving the cockpit. This is the click.
Steps
1. Voice gate (hard-block)
import { checkTone } from "../../lib/voice.ts";
const tone = checkTone(draft);
if (!tone.pass) {
// Score 0.0, do NOT post. Return violations so the caller can rewrite.
return { posted: false, reason: "voice-gate-failed", violations: tone.violations };
}
Em-dashes, hype words, AI tells - same gate content-polish uses. A draft that didn't go through content-polish first will usually fail here. That's the point.
2. Post
import { createLinkedInPost } from "../../lib/linkedin.ts";
const result = await createLinkedInPost(draft, dry_run);
// dry_run=true returns { dry_run: true, payload }
// real post returns { status: 201, headers: { ..., "x-restli-id": "<URN>" } }
The returned x-restli-id header is the post URN (e.g. urn:li:share:7234567890123456789). Capture it for step 3.
3. Verify it landed (independent re-fetch)
const urn = result.headers?.["x-restli-id"];
if (!urn) {
// POST returned 201 but no URN — should not happen; treat as scorer-bug.
return { posted: true, verified: false, reason: "no-urn-in-response" };
}
// Re-GET the post via the LinkedIn API. Different verb, same access token —
// satisfies actor-≠-auditor (POST writer vs GET reader are different code
// paths in linkedin.ts and exercise different LinkedIn endpoints).
import { linkedin } from "../../lib/linkedin.ts"; // un-export if needed, or
// add a public getPost(urn)
const fetched = await linkedin("GET", `/rest/posts/${encodeURIComponent(urn)}`);
const verified = fetched?.commentary === draft;
A LinkedIn outage between POST and GET could leave a real post un-verified (score 0.5 - see table). Don't retry the GET more than once; the POST already happened.
4. Score + log
import { score } from "../../lib/eval.ts";
score("linkedin-post", run_id, {
score:
!posted ? 0.0 :
posted && verified ? 1.0 :
posted && !verified ? 0.5 :
0.0,
posted,
verified,
urn: urn ?? null,
primary_issue:
!posted ? "voice-gate-failed" :
!verified ? "post-not-verified-via-get" :
null,
});
Eval
Actor: createLinkedInPost() in state/lib/linkedin.ts (POST /rest/posts). Auditor: linkedin("GET", "/rest/posts/<urn>") in the same file - a distinct exported verb that hits a different LinkedIn endpoint and decodes a different response shape. The token is shared (it has to be - LinkedIn's GET is gated on the same OAuth scope), but the read path is separate from the write path. If you want a stronger separation later, swap the auditor for an agent-browser scrape of the public profile feed.
Score convention:
| Outcome | Score |
|---|---|
| Voice-gated, posted, URN re-fetched and matches | 1.0 |
| Voice-gated, posted, URN missing or fetch fails | 0.5 |
| Voice gate failed (no post ever happened) | 0.0 |
createLinkedInPost threw (auth, rate, etc.) | 0.0 |
Dry-run runs do not log evals. A dry_run: true call returns the payload for inspection and exits - no scoring, no log row, no side effect.
Hard rules
- Voice gate runs FIRST. A draft that fails tone never reaches
/rest/posts. - Never call
createLinkedInPostmore than once perrun_id. If POST throws,
log 0.0 and surface - do not auto-retry. A duplicate post is worse than a failed one.
- The auditor must read back via GET; trusting the POST 201 alone is the
exact "exit code 0 ≠ success" trap that content-polish was built to catch.
Auth
LINKEDIN_ACCESS_TOKEN in .env.cache (static) or LINKEDIN_REFRESH_TOKEN + LINKEDIN_CLIENT_ID + LINKEDIN_CLIENT_SECRET for auto-refresh. refreshAccessToken() in state/lib/linkedin.ts:53 handles both. Token cache lives at ~/projects/snappy-os/.linkedin-token-cache.json (60-day TTL).
If auth is missing, createLinkedInPost throws with a message pointing to npx tsx state/lib/linkedin.ts auth - the OAuth bootstrap. Run that headed once per machine.
Gotchas
- LinkedIn's POST
/rest/postsreturns 201 with an empty body and a
x-restli-id header. Do NOT try to await res.json() on a 201 - the shared linkedin() helper already special-cases empty bodies (see state/lib/linkedin.ts:144-148).
- The returned URN sometimes uses
urn:li:share:...and sometimes
urn:li:ugcPost:... depending on visibility settings. Encode the URN before stuffing it into the GET path - : is a path-segment delimiter.
- If you bypass the
voice.checkTonegate "just this once" you will ship
an em-dash. The gate is the skill - the API call is the side effect.
- Use
dry_run: truefrom PiD when wiring the UI button; only flip to
real posts when the operator clicks the explicit "Publish" affordance.
Rubric
criteria:
- name: voice_gate_runs_first
kind: deterministic
check: "Verify that `voice.checkTone(draft)` from `state/lib/voice.ts` was executed and returned `pass: true` BEFORE any call to `createLinkedInPost`."
- name: post_call_made_once
kind: deterministic
check: "Confirm that `createLinkedInPost` from `state/lib/linkedin.ts` was called at most once per run_id. Multiple calls within the same run are a hard violation."
- name: urn_captured_from_response
kind: deterministic
check: "On a successful POST (status 201), the `x-restli-id` response header must be captured and persisted in the eval row's `urn` field."
- name: independent_get_verification
kind: deterministic
check: "After a successful POST, a `linkedin('GET', '/rest/posts/<urn>')` call must be issued and its `commentary` field compared to the original draft. The verified bool drives the 1.0 vs 0.5 split."
- name: dry_run_emits_no_eval
kind: deterministic
check: "When `dry_run: true`, no row is appended to `state/log/evals.ndjson` and no LinkedIn POST is issued."
- name: eval_row_shape_matches
kind: deterministic
check: "The eval row appended to `state/log/evals.ndjson` has keys `{ts, skill: 'linkedin-post', run_id, score, posted, verified, urn, primary_issue}` — no extras, no renames."AGENTS.md- what the AI loads when this skill comes up
linkedin-post - loader
Per-turn rules for the linkedin-post skill. Full reference: state/skills/linkedin-post/SKILL.md. Do not skip these.
Critical Rules
- ALWAYS run
voice.checkTone()fromstate/lib/voice.tsBEFORE callingcreateLinkedInPost- em-dashes, hype words, AI tells HARD-BLOCK - NEVER call
createLinkedInPostmore than once per run_id - a duplicate post on LinkedIn cannot be cleanly undone (delete leaves a gap) - ALWAYS capture
result.headers["x-restli-id"]- that URN is the only handle for the GET-back verification (memory: actor-≠-auditor) - The auditor MUST be a separate read path:
linkedin("GET", "/rest/posts/<urn>")- trusting the 201 alone is the same trap content-polish was built to catch - Use
dry_run: truefor any UI-driven preview; realdry_run: falseonly behind an explicit operator click - NEVER ship a post that wasn't drafted through
content-polishfirst - the voice gate here is a defense, not the primary check - LinkedIn UI is skill-owned (
ui_contract: branded). Readstate/skills/linkedin-post/resources/ui.openuibefore rendering or editing its dashboard/preview; do not move LinkedIn brand representation into a global snappy-chat rule.
Commands
| ui dashboard | state/skills/linkedin-post/resources/ui.openui | | schedule surface | state/skills/linkedin-post/resources/schedule.openui | |invoke: prose skill - follow steps in state/skills/linkedin-post/SKILL.md |lib: state/lib/linkedin.ts - createLinkedInPost(text, dryRun), linkedin(method, path, body) |voice gate: state/lib/voice.ts - checkTone() |auth: ~/.linkedin-token-cache.json (refresh via npx tsx state/lib/linkedin.ts auth) |eval log: state/log/evals.ndjson (skill: "linkedin-post")
OpenUI Resource
- Skill-owned OpenUI Lang resource:
state/skills/linkedin-post/resources/ui.openui. Read it before rendering or editing this skill's generated component surface. - Skill-owned OpenUI Lang resource:
state/skills/linkedin-post/resources/schedule.openui. Read it before rendering or editing this skill's generated component surface. Use it for natural "what is scheduled for LinkedIn" / Typefully queue inspection intents. It must keep// surface,// intents, and// responsemetadata at the top, stay reactive viaQuery("get_typefully_drafts"), and render withLinkedInPostPreview. - Skill-owned Lang shape (defineComponent, zod schema, view):
state/skills/linkedin-post/ui.tsx. This is the canonical source forLinkedInPostPreview- its props, its description, and its LinkedIn brand chrome. Snappy-chat re-exports it fromweb/src/genui/linkedin-post-preview.tsx; do NOT inline a parallel definition there. Surfaced + enforced bystate/lint/skill-shape-locality.ts. - Treat this resource as a first-class artifact of the skill, not a generic chat response. Improve it when the skill's user-facing output needs to become richer.
- System resources compose OpenUI primitives and inherit SnappyChat tokens. Use
ui_contract: brandedin SKILL.md only for deliberate platform or client visuals.
Known Pitfalls
- Skipping the voice gate to "just publish this draft real quick" ships the em-dash that makes the post obviously LLM-generated
- Calling
createLinkedInPostthen losing the URN before logging means the eval row has no handle to verify against - score is structurally 0.5 even on a perfect post await res.json()on the 201 throws because the body is empty - the sharedlinkedin()helper atstate/lib/linkedin.ts:144already handles this; do not re-implement- URN encoding:
urn:li:share:1234contains:which is a path delimiter -encodeURIComponentit before the GET - A LinkedIn rate-limit or transient 5xx between POST and GET makes the post real but the audit fail - that is the 0.5 row, not a retry signal
- Posting with
visibility: PUBLICfromcreateLinkedInPost(the default in linkedin.ts:202) means the post is live to the open feed the moment 201 returns; there is no draft state for a direct API post
Self-Test
An agent reading this should correctly:
- [ ] Run
voice.checkTone(draft)and refuse to post if it fails (returning violations to the caller)? - [ ] Capture the
x-restli-idURN from the POST response headers and persist it in the eval row? - [ ] Issue a separate
linkedin("GET", ...)with the URN to verify the post landed before scoring 1.0? - [ ] Score 0.5 (not 0.0) when the POST succeeded but the GET-back failed - the post is live; the audit just couldn't confirm it?
- [ ] Skip eval logging entirely when
dry_run: truewas passed?
api.ts- the code it can call
⚠ no api.ts - this skill has no typed action surface
scripts- helper scripts it can run
prose-only skill - 5 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 4 runs
| timestamp | verb | score | primary_issue | artifact |
|---|---|---|---|---|
| 2026-04-26 18:32Z | - | 0.00 | - | - |
| 2026-04-26 18:32Z | - | 0.00 | - | - |
| 2026-04-26 18:32Z | - | 0.00 | - | - |
| 2026-04-26 18:32Z | - | 0.00 | - | - |