OR Key
drop another .md file to compare - side-by-side diff against linkedin-post

linkedin-post

Publishes a post to LinkedIn after it passes your voice check.
description: "Triggers on prompt mention of 'linkedin-post', 'publish to linkedin', or '/run-skill linkedin-post'."
personal 2 files 4 recent evals

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.

Work with me
For developers how this skill is built, graded, and how it runs

at a glance- the short version

actorCreateLinkedInPost() in state/lib/linkedin.ts (POST…
auditorLinkedin("GET", "/rest/posts/<urn>") in the same
eval modeauto
categoryChannels
stages4
dependslinkedin, voice, drafts

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.

The skill
state/skills/linkedin-post/SKILL.md present
the skill itself, in plain text
The main file. It says what the skill is and lays out the steps in plain English.
Code
state/lib/linkedin-post.ts not present
code the skill can run
Optional. Many skills are just words and need no code at all.
Scripts
state/bin/linkedin-post/ not present
helper scripts
Optional. Added when a skill has a few commands to run.
Loader
state/skills/linkedin-post/AGENTS.md present
what the AI loads on the fly
Loaded automatically the moment this skill is needed. Kept short on purpose.

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.

name
kind
check
voice_gate_runs_first
deterministic
Verify that `voice.checkTone(draft)` from `state/lib/voice.ts` was executed and returned `pass: true` BEFORE any call to `createLinkedInPost`.
post_call_made_once
deterministic
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.
urn_captured_from_response
deterministic
On a successful POST (status 201), the `x-restli-id` response header must be captured and persisted in the eval row's `urn` field.
independent_get_verification
deterministic
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.
dry_run_emits_no_eval
deterministic
When `dry_run: true`, no row is appended to `state/log/evals.ndjson` and no LinkedIn POST is issued.
eval_row_shape_matches
deterministic
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.

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.

makes the work The worker
present
CreateLinkedInPost() in state/lib/linkedin.ts (POST… the worker
Does the actual work. Whatever it produces is what gets checked next.
checks the work The reviewer
present
Linkedin("GET", "/rest/posts/<urn>") in the same the checker
A separate checker grades the work, so the part that made it can't approve its own work.
frame
learns Self-correction
not present

This skill doesn't fix its own gaps yet.

tidies up Background fixes
present
queued for rewrite runs in the background
Bigger fixes that can't be made on the spot get queued and rewritten in the background later.
remembers Run history
present
state/log/evals.ndjson unknown runs
Every run is written down here, so the next time this skill is used it already knows how the last runs went.
Critical rules the things this skill must not get wrong
  1. ALWAYS run voice.checkTone() from state/lib/voice.ts BEFORE calling createLinkedInPost — em-dashes, hype words, AI tells HARD-BLOCK
  2. NEVER call createLinkedInPost more than once per run_id — a duplicate post on LinkedIn cannot be cleanly undone (delete leaves a gap)
  3. ALWAYS capture result.headers["x-restli-id"] — that URN is the only handle for the GET-back verification (memory: actor-≠-auditor)
  4. 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
  5. Use dry_run: true for any UI-driven preview; real dry_run: false only behind an explicit operator click
  6. NEVER ship a post that wasn't drafted through content-polish first — the voice gate here is a defense, not the primary check
  7. +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.

  1. Loading feedback rows…

how the work flows- who makes it, who checks it

inputs linkedinvoicedrafts
actor CreateLinkedInPost() in state/lib/linkedin.ts (POST…
1 generator
invoke
actor = CreateLinkedInPost() in state/lib/linkedin.ts (POST…
prose skill — follow steps in `state/skills/linkedin-post/SKILL.md
auditor Linkedin("GET", "/rest/posts/<urn>") in the same
1 auditor
Verify it landed (independent re-fetch)
```typescript
what this step does
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.
2 auditor
Score + log
```typescript

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:

OutcomeScore
Voice-gated, posted, URN re-fetched and matches1.0
Voice-gated, posted, URN missing or fetch fails0.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 createLinkedInPost more than once per run_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/posts returns 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.checkTone gate "just this once" you will ship

an em-dash. The gate is the skill - the API call is the side effect.

  • Use dry_run: true from 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() 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
  • LinkedIn UI is skill-owned (ui_contract: branded). Read state/skills/linkedin-post/resources/ui.openui before 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 // response metadata at the top, stay reactive via Query("get_typefully_drafts"), and render with LinkedInPostPreview.
  • Skill-owned Lang shape (defineComponent, zod schema, view): state/skills/linkedin-post/ui.tsx. This is the canonical source for LinkedInPostPreview - its props, its description, and its LinkedIn brand chrome. Snappy-chat re-exports it from web/src/genui/linkedin-post-preview.tsx; do NOT inline a parallel definition there. Surfaced + enforced by state/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: branded in 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 createLinkedInPost then 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 shared linkedin() helper at state/lib/linkedin.ts:144 already handles this; do not re-implement
  • URN encoding: urn:li:share:1234 contains : which is a path delimiter - encodeURIComponent it 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: PUBLIC from createLinkedInPost (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:

  1. [ ] Run voice.checkTone(draft) and refuse to post if it fails (returning violations to the caller)?
  2. [ ] Capture the x-restli-id URN from the POST response headers and persist it in the eval row?
  3. [ ] Issue a separate linkedin("GET", ...) with the URN to verify the post landed before scoring 1.0?
  4. [ ] 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?
  5. [ ] Skip eval logging entirely when dry_run: true was 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

rubric auto no rubric declared
recent mean 0.00 · 4 runs actor/auditor: unverifiable
deps linkedin voice drafts
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 - -