working-with-skills
Best practices for agents managing PostHog skills via the MCP `skill-*` tools — how to discover, read, create, update, and refactor skills efficiently,…
npx skills add https://github.com/posthog/ai-plugin --skill working-with-skillsWorking with PostHog skills
This skill teaches agents how to use the skill-* MCP tools well — minimum
context, minimum round-trips, minimum mistakes. If you are not yet familiar with
the tool surface itself, read the skills-store skill first for the catalog.
This document is about how to choose between the tools and how to scale the
workflow when skills get big.
Operating principles
- Progressive disclosure is non-negotiable. Lists return descriptions, get returns body + manifest, file-get returns one file. Never preload bundled files "just in case" — every preloaded script is wasted context for the actual task.
- Pick the smallest write primitive that does the job. A targeted
editsorfile_editsis cheaper, safer, and clearer in version history than a full body or full bundle replacement. - Reads are cheap; concurrent overwrites are not. Always have a recent
versionfromskill-get(or from the response of the previous write) before calling any write tool, and pass it asbase_version. - Authoring follows the Agent Skills spec.
Keep
namekebab-case, descriptions trigger-rich, body short, bulky material in bundled files.
Decision tree: which tool do I call?
Need to know what's available?
└─► skill-list (names + descriptions only)
Need to use / inspect a specific skill?
└─► skill-get (body + file manifest, NO file contents)
└─► skill-file-get (one file, on demand, only as referenced)
Authoring a brand new skill?
└─► skill-create (body + all initial files in one call)
Editing an existing skill?
├─ Body change?
│ ├─ Substantial rewrite ............. update(body=...)
│ └─ Surgical tweak .................. update(edits=[{old, new}, ...])
├─ Bundled file content change?
│ └─ update(file_edits=[{path, edits:[...]}, ...])
├─ Add / remove / rename a file?
│ ├─ Add ............................. skill-file-create
│ ├─ Delete .......................... skill-file-delete
│ └─ Rename .......................... skill-file-rename
└─ Wholesale bundle reset (rare!) ....... update(files=[...]) # replaces ALL files
Want a fork as the starting point?
└─► skill-duplicate (then update the copy)
Done with a skill entirely?
└─► skill-archive (hides ALL versions; cannot be undone)
If you find yourself reaching for update(body=...) plus a sprawling files=[...]
to change one paragraph and one script, stop — that's two narrower calls
(update(edits=[...]) plus update(file_edits=[...])) or even a single
update carrying both edits and file_edits.
Discover before you fetch
posthog:skill-list
{ "search": "fractal" }
skill-list is the right tool to "find a skill" — it returns names and
descriptions only. Reading the descriptions is the entire point: pick the right
skill before pulling any body. If search doesn't narrow it enough, list
without it and scan, but do not start fetching candidate bodies blindly.
skill-get should be called once per skill per task, not per question.
Cache the body in your working memory; fetch again only if you suspect the
skill changed under you (e.g. a 409 on write — see "Concurrency" below).
Reading a large skill efficiently
Big skills (long body, many bundled files) are the case where lazy loading matters most.
skill-get(skill_name=...)— readbody+files[]manifest.- Scan the body's table of contents / headings. The body should already tell you which file goes with which task — that's why bodies stay short and reference files by path.
- For each file the body explicitly points at for the current task, call
skill-file-get(file_path=...). Skip everything else. - If the body references "see scripts/X for the rare case Y" and you are not
in case Y, do not fetch
scripts/X.
When in doubt, fewer files. You can always fetch one more on the next turn.
Authoring a new skill
Use a single skill-create call with body and initial files — the
skill lands at version: 1 complete. Do not create the skill empty and then
make N follow-up skill-file-create calls; that's N extra versions and N
extra round-trips for no benefit.
posthog:skill-create
{
"name": "my-skill",
"description": "What it does AND when to use it. Include trigger keywords.",
"body": "# my-skill\n\n## When to use\n...\n## Workflow\n...",
"license": "MIT",
"compatibility": "Requires Python 3.10+",
"allowed_tools": ["Bash", "Write"],
"metadata": { "author": "me", "category": "..." },
"files": [
{ "path": "scripts/foo.py", "content": "...", "content_type": "text/x-python" },
{ "path": "references/primer.md", "content": "...", "content_type": "text/markdown" }
]
}
Authoring rules of thumb
descriptionis the discovery surface. It is the only thingskill-listreturns. Make it trigger-rich (what the user might say) and scope-honest (what the skill does and does not do).name— kebab-case, max 64 chars, no leading/trailing/consecutive hyphens. The spec validator rejects anything else.- Body ≤ ~500 lines. Long preambles, exhaustive SQL, full example payloads,
and runnable code belong in
references/,assets/, orscripts/. The body should route to those files, not inline them. - File layout convention —
scripts/for executable code,references/for prose docs and examples,assets/for templates / data. Agents can rely on this for orientation when they only have the manifest. allowed_toolslists the MCP / built-in tools the skill expects to be callable. Be honest — under-declaring causes silent failures, over-declaring is a security smell.
Updating an existing skill
The single most common mistake is using update(body=..., files=[...]) for a
small change. That works, but it round-trips the entire skill, makes the diff
unreadable in version history, and risks dropping files if files was
incomplete. Use the smallest primitive instead.
Always read first, capture version
posthog:skill-get
{ "skill_name": "my-skill" }
Note the returned version — pass it as base_version on every write. After a
successful write, the response contains the new version; chain further writes
with that.
Body: full replacement vs incremental edits
Full replacement when you are restructuring the body:
posthog:skill-update
{ "skill_name": "my-skill", "body": "# my-skill\n\nNew body...", "base_version": 7 }
Incremental edits when you are tweaking a few lines (preferred for small changes — easier to review, lower error surface):
posthog:skill-update
{
"skill_name": "my-skill",
"edits": [
{ "old": "Use Pillow for rendering.", "new": "Use Pillow ≥10.0 for rendering." },
{ "old": "## Old section title", "new": "## New section title" }
],
"base_version": 7
}
Each edits[].old must match exactly once in the current body, and body and
edits are mutually exclusive in one call.
Bundled file content edits
file_edits patches one or more existing files in place — non-targeted files
carry forward unchanged. This is the right primitive when you are tweaking
script logic or fixing a typo in a reference doc:
posthog:skill-update
{
"skill_name": "my-skill",
"file_edits": [
{
"path": "scripts/foo.py",
"edits": [{ "old": "ITERATIONS = 100", "new": "ITERATIONS = 250" }]
},
{
"path": "references/primer.md",
"edits": [{ "old": "## Outdated header", "new": "## Updated header" }]
}
],
"base_version": 7
}
file_edits cannot add, remove, or rename files — only patch
existing ones. For structural changes, use the per-file tools.
Combining edits in a single call
You can combine edits (body) and file_edits (existing files) in one
skill-update call to publish a single coherent version when a change
spans both:
posthog:skill-update
{
"skill_name": "my-skill",
"edits": [{ "old": "## Configuration", "new": "## Setup" }],
"file_edits": [
{ "path": "scripts/run.py", "edits": [{ "old": "DEBUG = False", "new": "DEBUG = True" }] }
],
"base_version": 7
}
File-path parameter naming (read this before guessing)
The same concept — a bundled file's path — is named differently depending on where it travels in the request, and this trips up agents working from memory. There is one rule:
file_path— when the path is part of the URL (skill-file-get,skill-file-delete). These read/delete one file addressed by its path.path— when the path is a body field:skill-file-create, thefiles=[{path, content, content_type}]array, andfile_edits=[{path, edits}].old_path/new_path— body fields onskill-file-rename.
Mnemonic: path is the field name on a file object (it sits next to
content), so everything that carries a file object uses path; the two
tools that address a file by URL use file_path. When unsure, check the
tool's input schema rather than guessing — passing path to file-get yields a
/files/undefined/ 404.
Adding, removing, renaming files
Each is its own call, each publishes a new version:
posthog:skill-file-create
{ "skill_name": "my-skill", "path": "scripts/julia.py", "content": "...", "base_version": 7 }
posthog:skill-file-delete
{ "skill_name": "my-skill", "file_path": "scripts/old.py", "base_version": 8 }
posthog:skill-file-rename
{ "skill_name": "my-skill", "old_path": "scripts/julia.py", "new_path": "scripts/julia_set.py", "base_version": 9 }
skill-file-rename is a true move — it carries the existing content
forward without resending it. Always prefer it over delete + create when the
content is unchanged.
When to use update(files=[...]) (rare)
Passing files to skill-update replaces the entire bundle —
anything not in the array is dropped. This is the right tool only when you are
intentionally wiping and reseeding the bundle (e.g. importing a fresh local
SKILL.md tree). For almost every other case, prefer file_edits plus per-file
CRUD.
Working with large multi-file skills
Skills with many files (10+) require extra discipline:
- Treat the manifest as the index.
skill-get'sfiles[]is your map. Match each task step to one file and fetch only that one. - Group structural changes into a sequence, not a fork. If you are renaming
three files, do them sequentially:
rename → rename → rename, each chained via the previous response'sversion. That gives you three small reviewable versions instead of one giantupdate(files=[...])blob. - Keep edits localised. A single
skill-updatewithfile_editstargeting five files is fine. A singleupdate(files=[...])carrying ten full file bodies is almost always a sign you should have usedfile_edits. - Refactor the body itself first. If the body has grown past ~500 lines, the right next step is usually to split content into new bundled files before adding more material.
Concurrency: base_version
Every write tool accepts base_version. Always pass it.
- The server compares
base_versionto the current latest version. If they match, the write succeeds and the new version isbase_version + 1. - If they differ, the write is rejected (someone else updated the skill).
Re-run
skill-get, reconcile your changes against the new body, and retry with the freshversion. - After a successful write, the response includes the new
version. Chain further edits with that — do not re-getbetween back-to-back writes you control.
Skipping base_version does not speed things up — it just turns a clean
"someone else won the race" error into a silent overwrite of their work.
Common pitfalls
- Calling
skill-listwith no search and then fetching every body — defeats progressive disclosure. Read the descriptions first. - Pre-fetching every bundled file after
skill-get— same mistake on the inner level. Fetch on demand from the body's directives. - Using
update(body=..., files=[...])for a one-line fix — round-trips the entire skill, makes diffs unreadable, and risks dropping files. Useedits/file_edits. - Using
update(files=[...])when you meant to add one file — drops every file you didn't include. Useskill-file-createinstead. - Delete + create instead of rename — loses content history and costs an extra version bump.
- Stale
base_versionafter chained writes — read theversionfrom the previous write's response, not from your initialget. - Leaving
base_versionoff — accepts a silent overwrite. Always include it once you've done aget. - Empty / vague
description— the skill becomes effectively undiscoverable viaskill-listsearch. Treat the description as the trigger contract. - Long body + no bundled files — when a body crosses ~500 lines, refactor
into
references/andscripts/rather than letting it grow. - Mixing
bodyandeditsin one update call — they're mutually exclusive. Pick one. - Guessing
pathvsfile_path— file-get and file-delete takefile_path(it's in the URL); create, rename (old_path/new_path),files, andfile_editstakepath(it's a body field). See "File-path parameter naming" above.
Archiving a skill
skill-archive hides every active version of a skill by name. It is
not version-scoped and cannot be undone — the skill drops out of
skill-list and skill-get for the whole team.
posthog:skill-archive
{ "skill_name": "my-skill" }
Before archiving, skill-get the skill if you need to inspect or copy it
first. Archiving is the right tool for retiring a skill entirely; to remove a
single bundled file use skill-file-delete, and to roll back content
publish a new version rather than archiving.
Porting a local SKILL.md tree into PostHog
When migrating a local skill folder (e.g. my-skill/SKILL.md plus
scripts/, references/, assets/):
- Read the local
SKILL.md. Its frontmatter maps toname,description,license,compatibility,allowed_tools,metadata. The body after the frontmatter becomesbody. - Walk the bundled subdirs and gather every file as
{ path, content, content_type }. - Call
posthog:skill-createonce with everything — the skill lands atversion: 1complete. Do not split this into a create + N file-create calls.
After the create, the skill is live for everyone via skill-get.
When a skill is the wrong answer
Not every persistent prompt belongs in the skills store:
- One-off task instructions belong in the conversation, not in a skill.
- Personal scratchpads belong in agent memory or local files.
- Code is not a skill — if it's something a service runs, it belongs in the repo.
A good skill is reusable, discoverable by description, and worth the cost of keeping it correct over time.