agentleFS
Scorpio-meowScorpio-meow/Threads-skill/threads-api/SKILL.md

threads-api

Skill0 starsChanged 25 days agoMITView on GitHub

Use for Meta's Threads API and embedding Threads posts: code that posts, reads, searches, moderates, pulls insights or handles tokens/webhooks, and posting, replying or pulling stats for the user.

  • Reads credentials

5 other projects use a version of this.

Adopted by

Most recently taken up by

  • Other versions5 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 5

    1. madebypan/threads-api-skill★ 69same name
    2. rawveg/skillsforge-marketplace★ 40same name
    3. fathinafiff/skills★ 7same name
    4. nikhonit/social-media-api-skills★ 3same name
    5. nathfavour/auracrab★ 1same name

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

  • In this repositoryOne other file it gives its agents.

The file

Show its rules and text

We split this skill into the rules it gives an agent

13 rules. 0 are common, 6 are distinctive, and 7 are very specific to this project.

  • 0

    Common

    Most of the 4 edited versions we read keep these.

  • 6

    Distinctive

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Publishing is two steps: create a container (POST /me/threads), then publish it (POST /me/threads_publish with creation_id).

    line 28
  2. Distinctive:

    Don't blindly retry 4xx errors.

    line 51
  3. Very specific:

    Reviewing or debugging: check §1 and §12–13 first.

    line 54
  4. Very specific:

    Text in one call: add auto_publish_text=true (TEXT only).

    line 75
  5. Distinctive:

    If the user enabled fediverse sharing, eligible API posts are shared to the fediverse too.

    line 81
  6. Very specific:

    Reply approvals: create with enable_reply_approvals=true.

    line 151
  7. Distinctive:

    Create the app with the Access the Threads API use case.

    line 189
  8. Distinctive:

    Test with Threads Testers: App roles → Roles → Add People → Threads Tester.

    line 190
  9. Very specific:

    Not a Threads post URL → 400, code 100, subcode 2207047.

    line 235
  10. Very specific:

    Add the class to one blockquote at a time and call instgrm.Embeds.process().

    line 270
  11. Distinctive:

    Wait until that embed reports or times out, then a few seconds more before the next (that gallery waits ≥ 3.6 s).

    line 271
  12. Distinctive:

    After a few failures in a row, assume a rate limit: pause (e.g.

    line 277
  13. Very specific:

    Is a saved post still there?: Ask oEmbed (≤ 1,000/hour) rather than loading threads.com pages, and match posts by shortcode (§11.1).

    line 280

“Common” is measured: kept by at least 6 in 10 of the 4 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Threads API

Playbook for Meta's Threads API, distilled from the official docs (synced 2026-10-23; the changelog's newest entry is 2026-08-18). It serves three jobs:

  • Build — write, review or debug code that calls the API.
  • Operate — call the API on the user's own account: post, reply, delete, hide replies, read insights.
  • Embed — show Threads posts on a website with oEmbed and embed.js, or add "share to Threads" buttons with Web Intents.

Statements are from the docs unless tagged (practice), which marks engineering advice. If the API behaves differently from what's written here, or the user asks about something newer than the snapshot, check the live changelog (https://developers.facebook.com/documentation/threads/changelog), or the user's local copy of the docs if one is available. Threads ads live in the Marketing API and are out of scope.

Contents: 1 Core facts · 2 Build mode · 3 Operate mode · 4 Publishing · 5 Reading & search · 6 Replies & moderation · 7 Insights · 8 Auth & permissions · 9 Quotas · 10 Webhooks · 11 Embedding, oEmbed & Web Intents · 12 Troubleshooting · 13 Doc examples not to copy · 14 Reference client (scripts/threads_client.py)

1. Core facts (most bugs come from missing one of these)

  • Base URL https://graph.threads.net/v1.0/ (graph.threads.com is equivalent). Include v1.0 on every Graph call; only the token endpoints in §8 are documented without it. me can stand in for {threads-user-id}.
  • Auth: send access_token (query string for GET/DELETE, form body for POST). These are Threads user tokens from the Threads OAuth flow (§8), not Facebook or Instagram tokens. A Meta app has two ID/secret pairs; use the Threads app ID / Threads app secret.
  • Media goes by URL. There is no upload endpoint: image_url / video_url must be publicly downloadable when Meta fetches them. Local files must be hosted first (practice: S3/GCS/R2/CDN; if signed, keep the URL valid until processing finishes).
  • Publishing is two steps: create a container (POST /me/threads), then publish it (POST /me/threads_publish with creation_id). Only text posts can skip step 2, via auto_publish_text=true. Containers not published within 24 h expire.
  • Container ID ≠ media ID. Step 1 returns a container ID (used only for status checks and publishing); step 2 returns the media ID used for everything afterwards (permalink, insights, replies, delete).
  • No native scheduling. To post later, the app runs the calls itself and should enforce the 250-posts/24 h quota itself (practice: create the container shortly before publishing, since containers expire).
  • Write and read enums differ — never compare one against the other:
When creatingWhen reading
post typemedia_type: TEXT, IMAGE, VIDEO, CAROUSELmedia_type: TEXT_POST, IMAGE, VIDEO, CAROUSEL_ALBUM, AUDIO, REPOST_FACADE (poll and GIF posts read back as TEXT_POST)
who can replyreply_control: everyone, accounts_you_follow, mentioned_only, parent_post_author_only, followers_onlyreply_audience: the same values in UPPER_CASE

Keyword search's media_type filter takes TEXT, IMAGE, VIDEO.

  • Parameter encoding: poll_attachment, text_entities, text_attachment and gif_attachment are JSON strings inside the form/query; children and allowlisted_country_codes are comma-separated; booleans are true/false.
  • Errors: the docs only show the flat shape from the code exchange, {"error_type": "OAuthException", "code": 400, "error_message": "…"}. Live Graph calls return the standard Meta envelope {"error": {"message", "type", "code", "fbtrace_id"}}, where code: 190 ("Invalid OAuth 2.0 Access Token") means a bad or expired token (observed from the live API; not in this doc set). Parse both; log fbtrace_id, never the token.

2. Build mode — writing or fixing code

  1. Match the project. Look for package.json/tsconfig.json (Node/TS: built-in fetch on Node 18+, or the project's HTTP client), pyproject.toml/requirements.txt (Python: the project's requests/httpx/aiohttp), *.csproj (C#: HttpClient), go.mod, and follow the existing style (sync vs async, config, logging). With no project and no stated stack, use Python with requests.
  2. Put the rules in the code, not in comments:
    • Config from the environment or a secret store (THREADS_ACCESS_TOKEN; THREADS_APP_ID/THREADS_APP_SECRET for OAuth). The app secret stays server-side.
    • One request helper that adds the token, encodes params per §1, raises a typed error carrying code/type/message/fbtrace_id, and never logs the token.
    • Publish = create → poll status until FINISHED (handle ERROR with its error_message, EXPIRED, and a timeout) → publish → fetch ?fields=permalink (§4.5). Keep that last lookup to permalink: fields such as reply_audience need extra permissions (threads_read_replies), and a failed lookup after a successful publish makes a good post look like a failure.
    • Validate inputs locally (§4.3–4.4) before spending quota; check threads_publishing_limit before batch or scheduled posting.
    • Paginate with paging.cursors.after, and limit ≤ 100 where the endpoint takes it (§5).
    • Don't blindly retry 4xx errors; back off on rate-limit errors.
  3. Read the reference client (scripts/threads_client.py, §14) and port its logic rather than improvising the flow.
  4. Deliver runnable files plus a line or two on configuring and running them. Size the work to the request: when the user asks for a function or module, give them that with the helpers it needs and a short usage example. Check it against a quick mock if that helps, but don't ship a large test suite, CLI or extra tooling nobody asked for; mention worthwhile extras in a sentence instead. Raise a gotcha only where the code handles it or the user's request runs into it.
  5. Reviewing or debugging: check §1 and §12–13 first. Usual culprits: write/read enum mix-ups, no publish step, publishing before FINISHED, non-public media URLs, the Facebook app ID/secret instead of the Threads pair, a missing permission or tester role, JSON params sent unencoded, curl -F on a GET.

Official extras: sample app https://github.com/fbsamples/threads_api, Postman collection https://www.postman.com/meta/threads/overview (may lag the docs).

3. Operate mode — acting on the user's account

Publishing, replying, reposting, deleting and hiding replies change a real, public account; deletes are permanent and limited to 100/day. Work through these steps in order:

  1. Find the token. Check THREADS_ACCESS_TOKEN in the environment, then a .env in the working/project folder. If neither exists, say so and ask the user to set it (or paste it, if they prefer). If they say it's already set but your shell can't see it, it most likely lives on their own machine: run the client there if you can reach that machine, otherwise hand over the command (step 7). Never echo the token, write it into chat, scripts or commits — pass it through the environment of the command that needs it. No token yet → walk them through §8.2–8.3.
  2. Verify with GET /me?fields=id,username before anything else, and tell the user which account you're about to act on. code 190 → expired or invalid (§8.4).
  3. Validate the request against §4.3–4.4 (length, link count, feature compatibility, poll limits, media types). If it can't be done as asked, explain the conflict and offer the closest valid alternatives; never silently drop or change a requested feature. Then check the request against what the user is trying to achieve, and mention anything that would undercut it: timing (when the post goes up, and when a ghost post or poll will be gone, relative to what it promotes), links that look like placeholders or test hosts, a poll whose question isn't in the text (polls have no question field), or a link the user expects to show as a preview card on a media post.
  4. Confirm before anything public or destructive. Show exactly what will be sent, in the reply itself rather than only in a file: account, final text verbatim, media URLs, every parameter (e.g. is_ghost_post=true, poll_attachment={…}) and the counted length. Then wait for an explicit yes. "Just post it" approves exactly the content the user gave; it doesn't cover anything you had to change or choose for them. Read-only calls (profile, posts, insights, quotas) need no confirmation.
  5. Execute with the reference client (scripts/threads_client.py, §14) where the token is available (the user's machine if the token lives there; copy the script over if that machine doesn't have this skill). Call it by its full path from the working folder, since it reads .env from the current folder (§14 covers skills whose files aren't on disk), and run check spec.json before publish spec.json.
  6. Report the media ID and permalink, or the exact error, what it means and the fix (§12). Never retry a failed publish blindly. The client logs each container ID to stderr; check it with get <container-id> fields=status,error_message. PUBLISHED means it already went out; FINISHED means publish that same container (post me/threads_publish creation_id=<container-id>) instead of creating a new one.
  7. If the API host can't be reached from your shell (proxy or DNS errors), say so and hand over the ready-to-run spec, command and token setup (PowerShell: $env:THREADS_ACCESS_TOKEN="..."; bash: export THREADS_ACCESS_TOKEN=...) instead of trying other routes. The command needs scripts/threads_client.py: give its full path if this skill is installed on the user's machine, otherwise hand over the file too.

4. Publishing

4.1 Flows

  • Text / image / video: POST /me/threads with media_type + text / image_url / video_url + options → wait for FINISHED (§4.5) → POST /me/threads_publish with creation_id=<container id> → {"id": "<media id>"}.
  • Text in one call: add auto_publish_text=true (TEXT only). The docs don't show what this call returns, so when you need the permalink afterwards, use the two-step flow or read the newest item of /me/threads.
  • Carousel (2–20 images/videos, mixing allowed; counts as one post): create each item with media_type=IMAGE|VIDEO, is_carousel_item=true, its URL and optional alt_text → (practice) wait until every item is FINISHED, videos especially → create the parent with media_type=CAROUSEL, children=<id1>,<id2>,… plus the post-level fields (text, reply_control, topic_tag, spoilers, …) → publish the parent.
  • Reply: a normal container plus reply_to_id=<media id> → publish. Needs threads_content_publish + threads_manage_replies, and either owning the root post or holding threads_keyword_search / threads_manage_mentions (which lets you answer public posts found via search or mentions). 1,000 replies/24 h. (practice: for long text, chain replies under your own post.)
  • Quote: a normal container plus quote_post_id=<media id>.
  • Repost: POST /{media-id}/repost (no container; needs threads_content_publish) → {"id": "<repost id>"}; reposts read back as REPOST_FACADE with reposted_post.
  • Delete: DELETE /{media-id} → {"success": true, "deleted_id": "…"}. Own posts only, needs threads_delete, 100 deletes/24 h, permanent.
  • If the user enabled fediverse sharing, eligible API posts are shared to the fediverse too.

4.2 Container parameters (POST /{user}/threads)

ParamWorks withNotes
media_typeallTEXT, IMAGE, VIDEO, CAROUSEL; always send it, carousel items included
textallRequired for TEXT; 500-char limit (§4.4)
image_url / video_urlIMAGE / VIDEOPublic URL
is_carousel_itemcarousel itemstrue
childrenCAROUSELComma-separated item container IDs, 2–20
reply_to_idanyMedia ID being answered
quote_post_idanyMedia ID being quoted
reply_controlanyeveryone, accounts_you_follow, mentioned_only, parent_post_author_only, followers_only
enable_reply_approvalsnot ghost postsReplies wait for approval (§6)
topic_tagany1–50 chars, no . or &; one topic per post; preferred over an in-text #tag
link_attachmentTEXT onlyURL shown as a preview card. Without it, the first URL in a text-only post becomes the card
poll_attachmentTEXT only{"option_a":"…","option_b":"…","option_c":"…","option_d":"…"}: a and b required, c and d optional, each 1–25 chars. There's no poll-duration parameter; read expiration_timestamp
gif_attachmentTEXT only{"gif_id":"<GIPHY id>","provider":"GIPHY"}. GIPHY only (Tenor support ended 2026-03-31)
text_attachmentTEXT only{"plaintext": "≤10,000 chars", "link_attachment_url": "…", "text_with_styling_info": [{"offset":0,"length":7,"styling_info":["bold","italic"]}]}; styles bold, italic, highlight, underline, strikethrough, ranges must not overlap. Not with a poll; no link_attachment_url if the post has link_attachment
text_entitiestext spoilers[{"entity_type":"SPOILER","offset":0,"length":5}], ≤10 entities. The parameter table calls offsets "positive whole numbers" while every example starts at 0; treat them as 0-based (practice: the docs don't define the offset unit; test with emoji or CJK text)
is_spoiler_mediaIMAGE, VIDEO, CAROUSELOn a carousel, set it on the parent; it covers every item
alt_textIMAGE, VIDEO (incl. carousel items)≤1,000 chars; not for TEXT
is_ghost_postTEXT onlyAuto-archives after 24 h. Can't be a reply, and reply approvals can't be enabled. Beyond that the docs only say text spoilers are the one supported feature, so expect polls, GIFs, link/text attachments and quotes not to work, and don't count on location, geo-gating, topic tags or cross-sharing either. The docs don't say whether the API rejects these with an error or silently drops them, nor whether a URL typed into the text gets a preview card; say that rather than promising either outcome
allowlisted_country_codesanyISO 3166-1 alpha-2, comma-separated (US,CA); only when GET /me?fields=is_eligible_for_geo_gating is true. The author always sees the post
location_idanyFrom GET /location_search (§5); needs threads_location_tagging
crossreshare_to_ig / crossreshare_to_ig_dark_modeanyAlso shares to the linked Instagram account as a Story; needs threads_share_to_instagram on top of threads_content_publish. The Story goes through the normal Instagram Story flow, so the account's Instagram privacy and Story-audience settings apply. The docs' text puts crossreshare_to_ig_status (SUCCESS/FAILED) on the publish response, their example on the container response, so check both. The Threads post goes out either way
auto_publish_textTEXT onlyPublish without step 2

4.3 Compatibility traps

  • TEXT-only features (link card, poll, GIF, text attachment, ghost post, auto-publish) can't be combined with image, video or carousel posts. A URL typed into the text of a media post stays plain text: it still counts toward the link limit but gets no preview card.
  • Ghost posts: TEXT only, never a reply, no reply approvals; per the docs, text spoilers are the only supported feature. If the user wants a poll, link card, GIF, text attachment or quote on a disappearing post, explain that the docs don't support it (they don't say whether the API errors out or silently drops it) and offer the alternatives (ghost post without it, or a regular post with it).
  • Polls: TEXT only and never together with a text attachment.
  • Links: at most 5 unique URLs per post, counting URLs in text plus link_attachment / text_attachment.link_attachment_url when different. More fails at container creation with THREADS_API__LINK_LIMIT_EXCEEDED.
  • In-text tags (legacy): the first valid #tag wins; it ends at whitespace, ., &, @, !, ?, ,, ; or :; #1 (digits only) isn't a tag.
  • Media spoilers need IMAGE, VIDEO or CAROUSEL; text spoilers go in text_entities.

4.4 Limits and media specs

  • Text 500 chars, with emojis counted as their UTF-8 byte length (most emoji 4, ☕️ 6). Text attachment 10,000; alt text 1,000; topic tag 50; poll option 25.
  • Carousel 2–20 items. 250 published posts per profile per 24 h (a carousel counts once).
  • Images: JPEG or PNG, ≤ 8 MB, aspect ratio ≤ 10:1, width 320–1440 px (scaled into range), sRGB (others converted).
  • Videos: MOV or MP4 with no edit lists and the moov atom at the front; H.264 or HEVC, progressive, closed GOP, 4:2:0; 23–60 fps; ≤ 1920 px wide; aspect 0.01:1–10:1 (9:16 recommended); ≤ 100 Mbps VBR; AAC ≤ 48 kHz, mono or stereo, 128 kbps; > 0 and ≤ 300 s; ≤ 1 GB. (practice) Conform a file with ffmpeg -i in.mov -vf "scale='min(1920,iw)':-2" -c:v libx264 -pix_fmt yuv420p -r 30 -c:a aac -ar 48000 -ac 2 -b:a 128k -movflags +faststart -use_editlist 0 out.mp4 (without -use_editlist 0, ffmpeg writes edit lists).

4.5 Container status

GET /{container-id}?fields=status,error_message → IN_PROGRESS, FINISHED, ERROR, EXPIRED (not published within 24 h) or PUBLISHED. error_message values: FAILED_DOWNLOADING_VIDEO, FAILED_PROCESSING_AUDIO, FAILED_PROCESSING_VIDEO, INVALID_ASPEC_RATIO (sic; match it exactly), INVALID_BIT_RATE, INVALID_DURATION, INVALID_FRAME_RATE, INVALID_AUDIO_CHANNELS, INVALID_AUDIO_CHANNEL_LAYOUT, UNKNOWN. The docs put average processing at about 30 s and recommend polling about once a minute for at most 5 minutes (practice: text and images are usually ready within seconds, so check early and back off; give videos the whole window). If threads_publish returns no media ID, check the status instead of creating another container.

5. Reading and search

  • Profile: GET /me?fields=id,username,name,threads_profile_picture_url,threads_biography,is_verified (also is_eligible_for_geo_gating, recently_searched_keywords). Only the token's own user.
  • Own posts: GET /me/threads?fields=…&since=…&until=…&limit=… returns top-level posts only (replies: §6). Fields: id, media_product_type, media_type, media_url, permalink, owner, username, text, timestamp, shortcode, thumbnail_url, children, is_quote_post, quoted_post, reposted_post, alt_text, link_attachment_url, gif_url, poll_attachment, topic_tag, is_spoiler_media, text_entities, text_attachment, has_replies, reply_audience, is_verified, profile_picture_url, location_id. Nested objects need sub-field syntax: poll_attachment{option_a,option_b,option_c,option_d,option_a_votes_percentage,option_b_votes_percentage,option_c_votes_percentage,option_d_votes_percentage,total_votes,expiration_timestamp}, location{id,name,address,city,country,latitude,longitude,postal_code}. permalink is omitted for copyright-flagged media; owner appears only on your own top-level posts.
  • One post: GET /{media-id}?fields=…. Until threads_basic has advanced access, only posts by Threads testers are readable.
  • Ghost posts: GET /me/ghost_posts (ghost_post_status ACTIVE/ARCHIVED, ghost_post_expiration_timestamp).
  • Mentions: GET /me/mentions?fields=… needs threads_manage_mentions; posts by private accounts are excluded.
  • Public profiles (threads_profile_discovery): GET /profile_lookup?username=<exact, no @> → username, name, profile_picture_url, biography, follower_count, likes_count, quotes_count, reposts_count, views_count, is_verified (follower_count is the current total; the likes, quotes, reposts and views counts cover the past 7 days); GET /profile_posts?username=…&fields=… takes the own-posts fields minus owner. Only public profiles with ≥ 100 followers. Each endpoint's page lists 1,000 requests per rolling 24 h without saying whether the two share one quota (practice: budget as if they do). With standard access only some official Meta accounts can be reached, including @meta, @threads, @instagram and @facebook.
  • Keyword / topic search (threads_keyword_search): GET /keyword_search?q=… with optional search_type=TOP|RECENT, search_mode=KEYWORD|TAG (TAG = topic-tag search), media_type=TEXT|IMAGE|VIDEO, author_username=<exact, no @>, since, until, limit, fields (no owner). 2,200 queries per user per 24 h across all apps; queries with no results don't count; sensitive keywords return []. The docs' search examples show media_type: "TEXT" in results where other endpoints return TEXT_POST, so accept both. Recent queries: GET /me?fields=recently_searched_keywords.
  • Locations (threads_location_tagging): GET /location_search?q=… or ?latitude=…&longitude=… (both together; can combine with the query), fields=id,name,address,city,country,latitude,longitude,postal_code; not paginated; 500 searches/24 h. One location: GET /{location-id}?fields=….
  • Paging and filters: /threads, /replies, /mentions, /ghost_posts and /profile_posts take since/until (Unix timestamp or a strtotime-style date; ≥ 1688540400; until ≤ now), limit (default 25, max 100) and before/after cursors (never both). /{media-id}/replies, /conversation and /pending_replies take only reverse and before/after (plus approval_status on pending replies). keyword_search takes since/until/limit but documents no cursors. List responses carry paging.cursors.before/after, and the docs say they include no next/previous URLs, so build the next request yourself with after=<cursor> and stop when there's no after or data comes back empty (practice: if a paging.next URL does show up, following it is fine, but code that depends on it stops after the first page).

6. Replies and moderation

  • Read (threads_read_replies): GET /{media-id}/replies (direct replies only; recurse where has_replies is true) or GET /{media-id}/conversation (all depths, flattened; call it on root posts). reverse defaults to true (newest first). Extra fields: root_post, replied_to, is_reply, is_reply_owned_by_me, hide_status (NOT_HUSHED, UNHUSHED, HIDDEN, COVERED, BLOCKED, RESTRICTED), reply_audience; username/permalink only for public users and yourself; is_verified/profile_picture_url only on direct replies. The user's own replies: GET /me/replies.
  • Hide/unhide (threads_manage_replies): POST /{reply-id}/manage_reply with hide=true|false. It works on top-level replies only, and hiding one hides its whole branch. A nested reply can't be hidden on its own: the only lever is hiding its top-level ancestor, which takes the ancestor and its other replies with it, so surface that trade-off rather than calling manage_reply on the nested ID. Only replies on the user's own posts can be moderated.
  • Near-real-time moderation: the replies webhook (§10) avoids polling; polling /{media-id}/replies also works within the app-level rate limit (§9).
  • Who can reply: reply_control at creation (§4.2).
  • Reply approvals: create with enable_reply_approvals=true; list with GET /{media-id}/pending_replies (approval_status=pending|ignored, case-sensitive, default both; field reply_approval_status); decide with POST /{reply-id}/manage_pending_reply and approve=true|false (ignored replies can still be approved later).

7. Insights (threads_manage_insights)

Per post: GET /{media-id}/insights?metric=views,likes,replies,reposts,quotes,shares → data[] of {name, period: "lifetime", values: [{value}], title, description, id}. Values are lifetime totals (practice: for daily numbers, store a snapshot each day and take differences). On a root post replies counts all replies, on a reply only direct ones; metrics of nested replies aren't included. Reposts (REPOST_FACADE) return an empty list. views and shares are labelled "in development".

Per account: GET /me/threads_insights?metric=…&since=<unix>&until=<unix>

MetricResponse shapeNotes
viewstime series values: [{value, end_time}]profile views
likes, replies, reposts, quotestotal_value: {value}replies counts top-level replies
clickslink_total_values: [{value, link_url}]clicks on shared links
followers_counttotal_value: {value}no since/until
follower_demographics"Total Value" per the docs, but the per-breakdown structure isn't shown; log the raw JSON first and parse defensivelyneeds breakdown=country, city, age or gender (exactly one per call); ≥ 100 followers; no since/until

Here since/until must be Unix timestamps ≥ 1712991600 (2024-04-13); the default range is yesterday → today; data before 2024-06-01 isn't guaranteed. (practice, not a documented rule: since those two metrics don't support since/until and demographics takes one breakdown per call, the simplest setup is one call for followers_count and one per breakdown, separate from the date-ranged metrics.)

8. Auth, permissions, app review

8.1 Permissions

PermissionNeeded for
threads_basicevery endpoint (always required)
threads_content_publishcreating/publishing posts, replies and reposts (also required for crossreshare_to_ig*); the post and reply fields of threads_publishing_limit
threads_manage_repliesPOST reply endpoints (publishing replies, manage_reply, manage_pending_reply)
threads_read_repliesGET replies, conversation, pending replies; reading the reply_audience field; reply webhooks
threads_manage_insightsinsights
threads_deletedeleting posts; delete webhooks
threads_keyword_searchkeyword/topic search (and replying to posts found that way)
threads_manage_mentionsmentions endpoint and webhooks (and replying to mentions)
threads_location_tagginglocation_search, location_id
threads_profile_discoveryprofile_lookup, profile_posts
threads_share_to_instagramcrossreshare_to_ig*

8.2 Before App Review

  • Create the app with the Access the Threads API use case; add permissions under Use cases → Customize. Settings holds the Threads app ID/secret, valid OAuth redirect URIs, and the deauthorize and data-deletion callback URLs.
  • Test with Threads Testers: App roles → Roles → Add People → Threads Tester; the person accepts under Threads → Settings → Account → Website permissions. Testers can grant every permission; test tokens can come from the Graph API Explorer or the OAuth flow.
  • Until a permission is approved (App Review, app published): publishing works only on behalf of the app's Threads Testers (the current docs no longer mention your own account, so add it as a tester too); GET /{media-id} only reads testers' posts; keyword search only finds the user's own posts; location search only answers the query "Menlo Park"; mentions only come from testers; profile discovery only reaches some official Meta accounts (including @meta, @threads, @instagram, @facebook).
  • To see what a user granted, call debug_token (§8.4) with the app token (TH|<APP_ID>|<APP_SECRET>) as access_token; it works for any user of the same app, tester or not. scopes shows grants, not App Review approval.

8.3 OAuth flow

  1. Send the user to https://threads.com/oauth/authorize?client_id=<THREADS_APP_ID>&redirect_uri=<registered URI>&scope=threads_basic,threads_content_publish,…&response_type=code&state=<csrf> (on Android open it in a browser or webview, not the app).
  2. The callback receives ?code=…#_; strip the trailing #_. Codes last 1 h and work once. A denial returns error=access_denied&error_reason=user_denied.
  3. Exchange it server-side: POST https://graph.threads.net/oauth/access_token (the docs' examples use graph.threads.com; both work) with client_id, client_secret, code, grant_type=authorization_code, redirect_uri (identical to step 1, and the dashboard may have added a trailing slash) → {access_token, token_type: "bearer", user_id}, valid 1 h.
  4. Long-lived: GET https://graph.threads.net/access_token?grant_type=th_exchange_token&client_secret=…&access_token=<short-lived> → {access_token, token_type, expires_in} (about 60 days). The short-lived token must still be valid; server-side only.

8.4 Keeping tokens alive

  • Refresh: GET https://graph.threads.net/refresh_access_token?grant_type=th_refresh_token&access_token=<long-lived> → a new 60-day token. Allowed once the token is ≥ 24 h old, and only while it's unexpired; an expired token can't be refreshed, so the user must authorize again.
  • (practice) Refresh on a schedule well before expiry (e.g. when < 10 days remain, or weekly), store the new token and its expiry (from expires_in) atomically, and alert a human when a refresh fails.
  • Permission grants last 90 days. Refreshing a long-lived token extends the grant for public profiles; for private profiles the grant can't be extended (their tokens still refresh), so those users must authorize again every 90 days. Read data_access_expires_at from debug_token and prompt re-authorization before it passes.
  • Inspect a token: GET /v1.0/debug_token?access_token=<app token, or a Threads tester's user token>&input_token=<token> → data{type, application, is_valid, issued_at, expires_at, data_access_expires_at, scopes[], user_id}. The app token (TH|<APP_ID>|<APP_SECRET>, next item) is accepted since 2026-08-18, so a server can inspect its users' tokens without a tester. Either way, access_token must come from the same app as input_token (the users can differ); a token issued by another app fails with "The App_id in the input_token did not match the Viewing App". scopes lists what the user granted, not whether App Review approved those permissions; check approval in the App Dashboard.
  • App token (server-side, for app-level calls): GET https://graph.threads.net/oauth/access_token?client_id=…&client_secret=…&grant_type=client_credentials → {"access_token": "TH|<APP_ID>|<APP_SECRET>", "token_type": "bearer"}, or pass that same string directly as access_token. It contains the app secret, so keep it out of logs, client code and app binaries.

9. Quotas and rate limits

Per profile, rolling 24 h: 250 posts (enforced at threads_publish), 1,000 replies, 100 deletes, 500 location searches; keyword search 2,200 queries; profile_lookup and profile_posts 1,000 requests each per their pages (whether shared isn't stated). oEmbed: 1,000 requests/hour (loading embeds on a page has no documented limit; see §11.3).

GET /me/threads_publishing_limit?fields=quota_usage,config,reply_quota_usage,reply_config,delete_quota_usage,delete_config,location_search_quota_usage,location_search_config → data[0] holds each *_usage plus a {quota_total, quota_duration: 86400} config. Permissions depend on the fields: post fields need threads_basic + threads_content_publish; reply fields add threads_manage_replies; delete fields need threads_basic + threads_delete; location-search fields need threads_basic + threads_location_tagging.

App-level, per app–user pair, rolling 24 h: calls ≤ 4800 × impressions (impressions count as at least 10); CPU time ≤ 720000 × impressions; total time ≤ 2880000 × impressions.

10. Webhooks

  • Setup: Use cases → Customize → Settings → add Get real-time notifications with Threads Webhooks; set the callback URL and verify token; subscribe to fields.
  • Fields: replies (topic moderate; threads_read_replies), delete (moderate; threads_delete), mentions (interaction; threads_manage_mentions, plus threads_read_replies for has_replies/is_reply/replied_to/root_post), publish (interaction; also fires for the user's replies).
  • Delivery requires the app in Live mode (Advanced Access for tech providers), a verified business, and the user's granted permissions; replies and mentions only fire on media owned by non-private accounts; delete and publish fire for public accounts or private ones that authorized the app. The docs conflict on App Review: the webhooks page says Advanced Access is needed to get notifications for all fields, the app-setup page says App Review isn't required — if a Live app gets no events, check Advanced Access next.
  • Payload (flat, not the entry[].changes[] shape of other Meta webhooks): {"app_id","topic","target_id","time","subscription_id","has_uid_field","values":{"value":{…media fields…},"field":"replies|mentions|delete|publish"}}. target_id is the media ID for replies and delete, but the mentioned user's ID for mentions. Delete payloads carry deleted_at.
  • (general Meta webhooks behavior, outside this doc set) Answer the GET verification by echoing hub.challenge when hub.verify_token matches; verify each POST's X-Hub-Signature-256 (HMAC-SHA256 of the raw body with the app secret); return 200 quickly and process asynchronously.
  • (practice) The same event can arrive more than once: make processing idempotent, keyed on values.field plus the media ID (values.value.id; for replies and delete that's also target_id), not on a hash of the raw body, since a redelivery isn't guaranteed to be byte-identical.

11. Embedding posts, oEmbed and Web Intents

11.1 Getting the embed HTML

  • oEmbed: GET https://graph.threads.net/v1.0/oembed?url=<URL-encoded post URL>&maxwidth=<320–658> → {type: "rich", version: "1.0", html, provider_name, provider_url, width}. Works for text, image, video and carousel posts, but not for posts from private, inactive or age-restricted accounts or geo-gated posts (observed 2026-10-24: posts Threads shows only to logged-in users, e.g. ones marked sensitive, still get HTML here, although most visitors can't see the embed; §11.2). 1,000 requests/hour. There's no maxheight: the embed is responsive, so its height follows the width and the content. Documented URL forms: https://www.threads.com/@{username}/post/{shortcode} and https://www.threads.com/t/{shortcode} (observed 2026-10: threads.net hosts, trailing slashes and query strings are accepted too; the returned HTML uses https://www.threads.com/t/{shortcode}?utm_source=th_embed&utm_campaign=threads_api as its permalink).
  • Post identity (practice): one post shows up under several URLs (both hosts, /t/ or /@user/post/, with or without a query, fragment or trailing slash). Key, dedupe and compare saved posts by the shortcode, taken from /(?:post|t)/([A-Za-z0-9_-]+) after dropping the query and fragment, rather than by the raw URL.
  • Token: none since 2026-03-03, per the changelog (observed 2026-10: token-less calls succeed, and responses, errors included, carry Access-Control-Allow-Origin: *, so page or extension code can call it and read the error body). The App Access Tokens page still says oEmbed needs an app token; if a token-less call is ever refused, call it server-side with one (§8.4), never from browser code.
  • Errors (observed 2026-10):
    • Missing or non-embeddable post → HTTP 400, code 24, error_subcode 4279056 ("Media Not Found", is_transient: false).
    • Not a Threads post URL → 400, code 100, subcode 2207047.
    • maxwidth outside 320–658 → HTTP 500, code 100 ("Param maxwidth must be…"). It's a validation error, so don't retry it.
  • By hand: on threads.com, open the post's share menu and choose Get embed code. The link text is in the viewer's UI language; oEmbed's is English. Otherwise it's the same markup as oEmbed's HTML.
  • Terms: the HTML, content and metadata may only be used to show the post. The docs rule out consuming, manipulating, extracting or persisting them for anything else, analytics included. (practice: keep the permalink as your record and fetch the HTML when you render or build the page; don't derive search indexes, stats or datasets from it. Treat embed code copied from the share menu the same way, since it's the same markup. Scraping threads.com pages instead is outside the API, and Meta's terms restrict automated collection.)

11.2 How the embed renders

Everything in this subsection was observed in embed.js and live embeds in 2026-10 (last checked 2026-10-23). It's undocumented and may change.

  • Markup: the HTML is <blockquote class="text-post-media" data-text-post-permalink="<permalink>" data-text-post-version="0" id="ig-tp-<shortcode>" style="…"> around a "View on Threads" link with the Threads logo, then <script async src="https://www.threads.com/embed.js">. The inline style hard-codes a white card (background:#FFF, min-width:270px, max-width:650px), so the fallback stays white on a dark page until the embed replaces it.
  • Lifecycle:
    • embed.js turns each blockquote.text-post-media into an iframe of <permalink minus query>/embed/<query>, inserted before the blockquote at height 0. The iframe takes over the text-post-media class and the blockquote's class becomes text-post-media-registered, so each blockquote is processed once.
    • It loads at most 3 at a time. A slot frees when an iframe reports its height or after 10 s. It also re-scans once, 5 s after the DOM is ready.
    • The embed page reports its height as a bare number via postMessage from https://www.threads.com (https://www.threads.net for a threads.net iframe): once it has rendered, and again whenever its content changes height (posts with images sent 2–4 reports as the media loaded). Resizing the iframe from outside doesn't bring a new report. Keep listening, and apply every report above 0.
    • On that report embed.js sets the iframe's style.height and removes the element whose id the blockquote had. A blockquote without an id is never removed, so its link stays under the rendered post.
    • embed.js sizes whichever iframe on the page sent the report, including iframes you created yourself.
  • What the report means:
    • A deleted post, or a shortcode that never existed, still renders a card: the embed page (HTTP 200) shows "Cannot display thread" (in the viewer's language), reports about 190 px, and embed.js removes the fallback just as for a real post. A replaced blockquote doesn't prove the post exists.
    • A post Threads shows only to logged-in users (e.g. marked sensitive) gets an HTTP 500 error page instead (Meta's "Sorry, something went wrong", still on threads.com, so the frame's URL and origin look normal), and no height report, for viewers who aren't logged in to Threads or whose browser blocks third-party cookies (Safari and private windows do by default). Logged-in viewers who allow third-party cookies see the post. oEmbed returns HTML for these posts as usual.
    • Reports of 0 happen (seen once in testing and in a production gallery; the trigger is unknown, and neither a 0-px starting height nor the same post twice on a page reproduced it). embed.js then leaves a 0-px iframe and, when the blockquote had its id, removes the fallback too, so the post vanishes without a trace. Treat 0 as a failure (§11.3).
    • An embed that never reports keeps its 0-px iframe, and the blockquote's link stays visible.
  • Dynamic pages: window.instgrm.Embeds.process() re-scans the page for new blockquotes. A <script> inserted through innerHTML never runs, so in SPAs or after adding posts dynamically, load embed.js once and call window.instgrm?.Embeds?.process() after each insert. embed.js needs <blockquote class="text-post-media" data-text-post-permalink="<permalink>" id="ig-tp-<shortcode>"> with a fallback link inside; keep the id unique on the page. (practice: when the HTML is stored or comes from elsewhere, build that markup with DOM methods from a validated threads.com URL instead of injecting stored HTML through innerHTML, which is an XSS risk.)
  • Theme: data-theme="dark" or "auto" on the blockquote adds ?theme=… to the iframe URL and makes its background transparent. The theme is fixed when the iframe is created; to switch, replace the iframe with a fresh blockquote and call process() again.
  • Raw iframe: don't iframe the permalink itself, because post pages send X-Frame-Options: DENY. Where scripts can't load (a strict CSP, Chrome extension pages), iframe <permalink>/embed/ directly, add ?theme=dark or ?theme=auto yourself, and set its height from each bare-number postMessage, ignoring 0. Check event.origin (both Threads hosts), and match event.source to the iframe's contentWindow. The report is a number, not an object, so a listener that expects {height} or Instagram's {type: "MEASURE"} drops it and makes it look as if no height is sent.
  • Placeholder height: embed.js sets style.height, so a CSS min-height on the iframe wins and leaves a blank band under every embed shorter than it. Put the placeholder on a wrapper, or set the iframe's min-height from the report yourself.
  • Site policies: allow https://www.threads.com in script-src and frame-src. The iframe's host follows the permalink, so old threads.net links produce www.threads.net iframes: either rewrite them to www.threads.com or allow both hosts. The embed page sends Cross-Origin-Resource-Policy: same-origin, so a page with Cross-Origin-Embedder-Policy: require-corp can't show it.

11.3 Pages with many embeds

These are (practice) items from a production gallery of hundreds of posts and from tests of live embeds.

  • The problem: even with embed.js's 3-at-a-time cap, loading dozens of embeds in a row gets rate-limited (HTTP 429), and the remaining posts stay as plain links. Meta documents no limit for these loads; the oEmbed quota covers API calls only.
  • Pacing:
    • Show about 10 per page, or only process cards near the viewport.
    • Render the blockquotes without the text-post-media class (mark them, e.g. data-embed-pending).
    • Add the class to one blockquote at a time and call instgrm.Embeds.process().
    • Wait until that embed reports or times out, then a few seconds more before the next (that gallery waits ≥ 3.6 s).
    • embed.js never picks up a blockquote twice (§11.2). To retry a failed embed, remove its iframe and put the text-post-media class back on its blockquote.
  • Detecting failure: the page can't see a cross-origin iframe's HTTP status. Fetch/XHR wrappers, console.error overrides, window error events and a service worker don't see these loads either, since the iframe's requests and errors stay in its own context; only a failed load of embed.js itself is visible. Judge by outcome.
    • Listen for the height report yourself (same origin and source checks as above). Success means a first report above 0 within ~12 s; a 0 report or none at all is a failure. Count only numeric reports: browser extensions that inject scripts into Threads frames can post their own messages from the same origin (a gallery that counted any threads.com message as success marked error pages as loaded).
    • The iframe's load event isn't a signal: it fires for unavailable posts too, and for real ones it can come long after the report.
    • Height can't tell a short text post from a "Cannot display thread" card. To learn whether a post is gone, ask oEmbed (last item).
    • After a few failures in a row, assume a rate limit: pause (e.g. 60 s, growing × 1.5 each time, capped at 5 min), then resume more slowly.
    • Hidden tabs throttle timers and don't run requestAnimationFrame at all, so a render step that waits on rAF stalls until the tab is shown; use a timer for it. Don't mark an embed failed while document.hidden; re-check once the tab is visible.
  • Fallback card: for a failed embed, link to the post (allow only threads.com/threads.net URLs) and list the likely causes: rate limiting, a content or tracking blocker, a CSP or network problem, or a post Threads shows only to logged-in users (the viewer isn't logged in to Threads or blocks third-party cookies). Don't blame a deleted post here: a missing post renders its "Cannot display thread" card and counts as loaded (§11.2), so finding gone posts is oEmbed's job (next item).
  • Is a saved post still there? Ask oEmbed (≤ 1,000/hour) rather than loading threads.com pages, and match posts by shortcode (§11.1). code 24 means it can no longer be embedded; treat other errors as transient and retry later.
  • Login-only posts (observed 2026-10-24): oEmbed can't flag them. Requesting <permalink>/embed without credentials, as a same-origin request from a threads.com page (e.g. an extension's content script), tells them apart: HTTP 500 means it doesn't embed for logged-out viewers, 200 with class="Embed" in the body embeds publicly, and 200 without it is the "Cannot display thread" card. Plain server-side requests got a generic page shell for every post in testing, so the check only worked from threads.com. Keep trying such posts in the page, since logged-in visitors can see them, and say on the fallback card that the post needs both a Threads login and third-party cookies allowed (a logged-in Safari visitor still can't see it).

11.4 Web Intents

  • Web Intents (no app or token): https://www.threads.com/intent/post?text=…&url=…&tag=…&reply_control=everyone|accounts_you_follow|mentioned_only|followers_only&reply_post_shortcode=…&quote_post_shortcode=… and https://www.threads.com/intent/follow?username=…; percent-encode values. The right tool for "share to Threads" buttons, where visitors post from their own accounts.

12. Troubleshooting

SymptomCause → fix
code 190 / "Invalid OAuth 2.0 Access Token"Expired, malformed or non-Threads token → refresh or re-authorize; inspect with debug_token (§8.4)
"Matching code was not found or was already used"Code reused, older than 1 h, or #_ left on → restart authorization
Code exchange rejects redirect_uriNot identical to the authorize step and dashboard entry (watch trailing slashes)
OAuth fails although the app ID looks rightFacebook app ID/secret used instead of the Threads pair
Permission errors or unexpectedly empty resultsScope not requested/granted, account not a Threads Tester, or permission not approved yet → check scopes with debug_token and the app token, approval in the App Dashboard (§8.2)
Container ERROR + FAILED_DOWNLOADING_VIDEO, or an image never processesMedia URL not publicly downloadable (auth, expired signature, blocked host)
INVALID_* video errorsFile outside the spec → transcode (§4.4)
EXPIREDContainer older than 24 h → create a new one
Publish returns no media IDStill processing → poll the status; don't create a duplicate
THREADS_API__LINK_LIMIT_EXCEEDEDMore than 5 unique links
THREADS_API__FEATURE_NOT_AVAILABLEThe account lacks the feature (e.g. geo-gating)
THREADS_API__GEO_GATING_INVALID_COUNTRY_CODESUnsupported country code → ISO 3166-1 alpha-2
Keyword search finds only own posts / location search only "Menlo Park"Permission not approved yet (§8.2)
profile_lookup failsPrivate account, < 100 followers, inexact username, or standard access only
A post's insights are emptyIt's a repost (REPOST_FACADE)
follower_demographics errorsMissing or multiple breakdown, since/until sent, or < 100 followers
Quota errorsCheck threads_publishing_limit; spread posts out
No webhooks arriveApp not Live / no Advanced Access, business unverified, permission missing, or private account
Embed stays a plain "View on Threads" linkembed.js never ran (inserted via innerHTML → call instgrm.Embeds.process()); blockquote lacks the text-post-media class; blocked by CSP, COEP or a content blocker; rate-limited after many embeds (§11.2–11.3)
Embed shows "Cannot display thread"Post missing or not embeddable → confirm with oEmbed (code 24)
Embed fails only for logged-out visitors, or in Safari / private windowsPost shown only to logged-in users (e.g. marked sensitive): its /embed page is an HTTP 500 error without a Threads login and third-party cookies, although oEmbed returns HTML (§11.2)
Post renders but a "View on Threads" link stays under itBlockquote has no id (ig-tp-<shortcode>), so embed.js can't remove it (§11.2)
Embed leaves an empty gap or disappearsA height report of 0; or a CSS min-height on the iframe overriding the reported height (§11.2)
"Refused to display … X-Frame-Options 'deny'"Iframing the post page → use embed.js or <permalink>/embed/
oEmbed HTTP 500, code 100, "Param maxwidth…"maxwidth outside 320–658

13. Doc examples not to copy verbatim

Several official examples are broken; write correct requests instead:

  • curl -X GET -F … (keyword search, insights) sends a multipart body. For GET use curl -G --data-urlencode 'q=…' ….
  • Missing ? before access_token (Polls and Location retrieval examples); unbalanced quotes inside -d values (GIF, Links examples); access_tokens= (should be access_token) in the app-credential shortcut; acceess_denied (the real value is access_denied).
  • Share to Instagram Stories: media_type=text (must be TEXT), -d "crossreshare_to_ig=true" | "crossreshare_to_ig_dark_mode=true" (the | is a shell pipe; send one -d per parameter) and a missing \ before the URL line.
  • The carousel-item example omits media_type, which the parameter table requires.
  • Some examples omit /v1.0 (mentions, pagination); include it.
  • JSON params appear unescaped inside shell quotes; JSON-encode them (json.dumps, JSON.stringify) and URL-encode the form.
  • oEmbed: the example has a stray backslash and an un-encoded post URL; use curl -G --data-urlencode "url=<post URL>" https://graph.threads.net/v1.0/oembed.
  • Location search: the guide uses q, the reference lists query. Send q; if results ignore the query, try query.

14. Reference client (Python 3.8+, standard library only)

The client is scripts/threads_client.py (paths here are relative to this skill's folder). Run it directly in operate mode; in build mode, read it and port its logic: call() (token, §1 encoding, typed errors), check() (§4.3–4.4 validation), wait_ready() (status polling, §4.5) and publish() (the whole flow, ending with the permalink lookup).

It takes the token from THREADS_ACCESS_TOKEN or from .env in the current folder, so run it from the working folder and give the script's full path:

python3 scripts/threads_client.py get me fields=id,username
python3 scripts/threads_client.py get me/threads fields=id,text,permalink,timestamp limit=5
python3 scripts/threads_client.py check spec.json     # local validation only, no API call
python3 scripts/threads_client.py publish spec.json   # validate, create, wait, publish, print permalink
python3 scripts/threads_client.py post <media-id>/repost
python3 scripts/threads_client.py post <reply-id>/manage_reply hide=true
python3 scripts/threads_client.py delete <media-id>

If this skill's files aren't on disk (some hosts serve them on request and don't allow saving them), get scripts/threads_client.py from wherever you loaded this skill and feed it to Python on stdin instead of saving it: python3 - check spec.json, with the script as stdin (a heredoc in bash). The arguments are the same.

Results print as JSON on stdout. Failures exit with status 1 and print the reason on stderr; for API errors that's ERROR: HTTP <status> code=<code> <type>: <message> (fbtrace_id=…). spec.json holds container params; for a carousel, put the item params in "children" (a list of dicts) and the client creates the item containers first. publish logs every container ID to stderr; if it stops midway, follow §3 step 6 before retrying.

Spec examples for check / publish:

  • Poll: {"media_type": "TEXT", "text": "Light or dark roast?", "poll_attachment": {"option_a": "Light", "option_b": "Dark"}}
  • Carousel: {"media_type": "CAROUSEL", "text": "…", "topic_tag": "Photography", "reply_control": "accounts_you_follow", "children": [{"media_type": "IMAGE", "image_url": "https://…/1.jpg", "alt_text": "…"}, {"media_type": "VIDEO", "video_url": "https://…/clip.mp4"}]}
  • Reply: {"media_type": "TEXT", "text": "Thanks!", "reply_to_id": "<media id>"}

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.