Hashtags.
Write #storymode in a post body and Social indexes it. The directory is per-community, the parser lowercases + dedupes + caps at 30 per body. Indexed inside the same DB transaction as the post insert — a failed post never leaves orphan tag rows.
1
Use #tags in a post
Just write #whatever in body. The parser pulls every #[a-z0-9_]{1,32} match (case-insensitive, lowercased on store), dedupes, and caps at 30 per body. Anything beyond 30 is silently dropped, and a tag longer than 32 characters matches nothing at all — it is never truncated into a junk tag.
curl -X POST \
-H "Authorization: Bearer pcft_live_..." \
-H "Content-Type: application/json" \
-d '{"actor_id":"<actorId>","body":"big day! #storymode #v2 launch is live"}' \
"https://social.productcraft.co/v1/communities/<communityId>/posts"2
Browse the directory
Per-community, ordered by first_used_at desc — newest tags first. Cursor-paginated.
# newest tags (default)
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/hashtags?limit=20"
# trending — most posts in a window. window_post_count is populated
# only in this mode. Results are cached for 5 minutes.
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/hashtags?order=trending&window=24h"order=trending ranks tags by their published-post count inside window=24h or 7d (counted through post.created_at). The literal tag #trending is unaffected — it stays reachable at /hashtags/trending/posts.
3
Posts for a tag
order=chronological (default) pages by post created_at desc; order=ranked reuses the discover recency+engagement scoring and pages on a (score, id) cursor. Both return only published posts — drafts, removed, hidden, expired, and private-author posts are filtered out. An unknown tag is a 404.
# ranked — best posts for the tag, not just newest
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/hashtags/storymode/posts?order=ranked&limit=20"4
Prefix autocomplete
Backed by a text_pattern_ops index on (community_id, tag) — fast even on millions of tags. q is required and case-insensitive (the server lowercases it for you); limit defaults to 20, max 50. The envelope is the standard one, but pagination is always null — autocomplete returns a single bounded page with no cursor.
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<communityId>/search/hashtags?q=sto&limit=10"5
Tag detail with post count
Returns the directory row plus a live count of currently- published posts using the tag.
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<communityId>/hashtags/storymode"{
"community_id": "...",
"tag": "storymode",
"first_used_at": "2026-04-01T12:00:00.000Z",
"window_post_count": null,
"post_count": 17
}Internals
Why the transaction matters
Hashtag rows are inserted INSIDE the same DB transaction as the post insert. The naive approach — insert the post, then insert the hashtags — produces orphan directory entries when the post insert fails: the hashtag row sits in the directory pointing at a post that never came into existence.
The TX-aware path also covers updates: editing a post body re-runs the parser, replaces the post'spost_hashtag rows, and ensures any new tags appear in the directory — all in one TX. If the update rolls back, the index doesn't drift.
Permissions
PAK scopes
- List + posts-for-tag + autocomplete:
social.listonpcft:agora:community/<communityId>. - Tag detail:
social.readon the same URN. - Writes ride the post-create / post-update endpoints and use those scopes; no extra grant needed for the hashtag side-effect.