OctoTown — Specification v3.0
A decentralized, public-only social network using GitHub as storage, with profile caching via GitHub Actions.
Overview
- Target users: GitHub users only
- Storage: GitHub Issues as posts, GitHub's native follow system for social graph
- Identity: User's
.socialrepository (e.g.,github.com/username/.social) - Feed: Fetched directly from GitHub API (Issues from followed users'
.socialrepos) - Social Graph: Uses GitHub's REST API (
GET /users/{username}/following) to determine who a user follows
Repository Structure: <username>/.social
.social/
├── README.md # Repository description
├── following/
│ └── <username>.yml # Cached profile for each followed user
├── hidden/
│ └── <username> # Hidden users (excluded from feed)
├── hidden-threads/
│ └── <user>-<issue_id> # Hidden threads (excluded from feed)
├── muted/
│ └── <user>-<issue_id> # Muted threads (no notifications)
└── notifications/
├── .last # ISO 8601 timestamp of last notification check
└── <timestamp>-<type>-<user>-<id>.yml # Notification entries
Following Cache (following/<username>.yml)
Each followed user has their own profile file in the following/ directory, auto-generated by the profile-sync workflow. This allows front-end apps to read cached profile data instead of making quota-restricted API calls.
File: following/alice.yml
# Profile for alice - auto-generated by feed-sync workflow
last_updated: 2026-01-27T12:00:00Z
login: "alice"
name: "Alice Smith"
bio: "Software developer and open source enthusiast"
location: "San Francisco, CA"
blog: "https://alice.dev"
avatar_url: "https://avatars.githubusercontent.com/u/12345"
followers: 1234
following: 567
twitter_username: "alicedev"
company: "Acme Corp"
has_social_repo: true
| Field | Description |
|---|---|
last_updated |
When the profile was last fetched from GitHub API |
login |
GitHub username |
name |
Display name |
bio |
User bio |
location |
Geographic location |
blog |
Personal website URL |
avatar_url |
Profile picture URL |
followers |
Follower count |
following |
Following count |
twitter_username |
Twitter/X handle |
company |
Company/organization |
has_social_repo |
Whether user has a .social repository |
Caching Strategy:
- Each profile has its own
last_updatedtimestamp - Profiles are refreshed if older than 24 hours
- New followed users are fetched immediately
- Unfollowed users have their profile files automatically removed
- This reduces API usage significantly for stable following lists
Note: Only users with has_social_repo: true will have their posts synced to the feed.
Social Graph
OctoTown leverages GitHub's native follow system instead of maintaining separate files:
| Action | GitHub API |
|---|---|
| Get following | GET /users/{username}/following |
| Get followers | GET /users/{username}/followers |
| Follow user | PUT /user/following/{username} |
| Unfollow user | DELETE /user/following/{username} |
| Check if following | GET /user/following/{username} |
This approach:
- Eliminates the need for manually managing social graph files (no
followers.mdor follow PRs) - Uses the
following/directory only for cached profile data (auto-generated by feed-sync) - Removes the complexity of auto-accepting follower PRs
- Uses GitHub's existing infrastructure for social connections
- Provides real-time follower/following counts via the API
Muted Threads (muted/<user>-<issue_id>)
Users can mute specific threads to stop receiving notifications for them. Each muted thread is represented by an empty file in the muted/ directory.
Filename pattern: <user>-<issue_id>
Example: muted/alice-42 (mutes thread https://github.com/alice/.social/issues/42)
| Operation | Action |
|---|---|
| Mute | Create empty file muted/<user>-<issue_id> |
| Unmute | Delete file muted/<user>-<issue_id> |
| Check | File exists = muted |
Client behavior:
- Muted threads should not trigger notifications
- Muted threads still appear in feed (muting ≠ hiding)
- UI should indicate when viewing a muted thread
- Provide easy toggle to mute/unmute from thread view
Hidden Threads (hidden-threads/<user>-<issue_id>)
Users can hide specific threads to completely remove them from their feed. Each hidden thread is represented by an empty file in the hidden-threads/ directory.
Filename pattern: <user>-<issue_id>
Example: hidden-threads/alice-42 (hides thread https://github.com/alice/.social/issues/42)
| Operation | Action |
|---|---|
| Hide | Create empty file hidden-threads/<user>-<issue_id> |
| Unhide | Delete file hidden-threads/<user>-<issue_id> |
| Check | File exists = hidden |
Feed sync behavior:
- The feed-sync workflow should skip posts that match a hidden thread
- Existing feed files for hidden threads may be deleted or left to expire
Client behavior:
- Hidden threads should not appear in the feed view
- Provide a way to view and manage hidden threads
- Hiding a thread also implicitly mutes it (no notifications)
Notifications (notifications/)
Notifications alert users about activity relevant to them. Notifications are stored as YAML files in the notifications/ directory.
Filename pattern: <timestamp>-<type>-<user>-<id>.yml
Example: notifications/2026-01-27T12-00-00Z-reply-alice-42.yml
Notification Types
| Type | Trigger | Description |
|---|---|---|
reply |
Comment on your post | Someone replied to your post |
repost |
Repost of your post | Someone reposted your content |
quote |
Quote of your post | Someone quoted your content |
mention |
@username in post/comment | Someone mentioned you |
follow |
New follower with .social repo | An OctoTown user followed you |
Notification File Format
type: reply
timestamp: 2026-01-27T12:00:00Z
read: false
actor: alice
actor_avatar: https://avatars.githubusercontent.com/u/12345
target_url: https://github.com/you/.social/issues/42
target_title: "post: My awesome post"
content: "Great post! I totally agree."
| Field | Description |
|---|---|
type |
Notification type (reply, repost, quote, mention, follow) |
timestamp |
When the activity occurred |
read |
Whether the user has seen this notification |
actor |
Username who triggered the notification |
actor_avatar |
Avatar URL of the actor |
target_url |
URL of the relevant post/issue |
target_title |
Title of the target post (for context) |
content |
Preview of the content (for replies/quotes) |
Notification Generation
The feed-sync workflow should generate notifications for:
- Replies: New comments on the owner's posts
- Reposts/Quotes: New issues referencing the owner's posts
- Mentions: Posts/comments containing
@owner
Notifications are not generated for:
- Muted threads (check
muted/directory) - Hidden threads (check
hidden-threads/directory) - The owner's own activity
Client Behavior
- Display unread notification count as a badge
- Provide a notifications view to browse all notifications
- Mark notifications as read when viewed
- Allow clearing/dismissing notifications
User Profile
OctoTown uses GitHub's REST API to fetch user profile data instead of maintaining a separate profile file:
GET /users/{username}
API Documentation: https://docs.github.com/en/rest/users/users#get-a-user
| GitHub Field | OctoTown Usage |
|---|---|
login |
Username / handle |
name |
Display name |
bio |
User tagline / short bio |
location |
Geographic location |
blog |
Personal website URL |
avatar_url |
Profile picture |
followers |
Follower count |
following |
Following count |
twitter_username |
Twitter/X handle |
company |
Company/organization |
This approach:
- Eliminates the need for a
profile.mdfile - Keeps profile data in sync with the user's GitHub profile
- Reduces repository complexity
- Leverages GitHub's existing profile infrastructure
Post Format Conventions
| Type | Title Format | Content Limit |
|---|---|---|
| Post | post: <text> |
250 chars (256 - 6 for prefix) |
| Repost | repost: <issue_url> |
Empty body = pure retweet |
| Quote | repost: <issue_url> |
First 250 chars of body = plain text quote |
| Reply | Comment on Issue | 250 chars |
Labels
Posts can have labels (similar to Reddit flairs) using GitHub's native issue labels feature. Labels help categorize posts and make them easier to discover.
| Aspect | Description |
|---|---|
| Storage | GitHub Issue labels |
| Who can add | Post owner only (at creation or via edit) |
| Format | Any valid GitHub label name |
| Display | Clients should display labels as colored badges |
Label Object Structure:
interface Label {
name: string; // Label name (e.g., "announcement")
color: string; // Hex color without # (e.g., "0366d6")
description?: string; // Optional description
}
Common label examples:
question- Asking for help or opinionsannouncement- Important updatesdiscussion- Open conversationshowcase- Sharing work or projectsofftopic- Non-technical content
API for labels:
POST /repos/{owner}/.social/issues
{
"title": "post: My awesome post",
"labels": ["announcement", "showcase"]
}
Note: Labels must exist in the repository before they can be used. The .social-template repository should include common labels. Users can create custom labels via GitHub's UI or API.
Front-end Implementation Guidelines
Displaying labels:
| Label Count | Display Behavior |
|---|---|
| 0 | No labels shown |
| 1 | Show label directly as a colored badge |
| 2+ | Show a "Labels..." button that reveals all labels on hover |
Color contrast: Clients should calculate text color based on background brightness to ensure readability:
function getTextColor(hexColor) {
const r = parseInt(hexColor.substr(0, 2), 16);
const g = parseInt(hexColor.substr(2, 2), 16);
const b = parseInt(hexColor.substr(4, 2), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness > 128 ? '#000000' : '#ffffff';
}
Parsing labels from feed files: Labels in feed files may be stored as either strings or objects. Clients must handle both formats:
function parseLabel(label) {
if (typeof label === 'string') {
return { name: label, color: '888888' }; // default gray
}
return {
name: label.name,
color: label.color || '888888',
description: label.description
};
}
Examples
Post:
Title: post: Just shipped a new feature! Check it out 🚀
Body: (optional extended content, media, Markdown)
Labels: ["announcement"]
Repost (pure retweet):
Title: repost: https://github.com/alice/.social/issues/42
Body: (empty)
Quote repost:
Title: repost: https://github.com/alice/.social/issues/42
Body: This is so true! Everyone should read this.
(first 250 chars must be plain text, remainder can include Markdown/media)
Feed
The feed is fetched directly from the GitHub API. Clients query Issues from the .social repositories of followed users in real-time.
Feed Fetching (Client-Side)
- Get list of followed users via
GET /users/{owner}/following - For each followed user (skip if in
./hidden/):- Check if user has a
.socialrepository (cached in./following/<username>.yml) GET /repos/<user>/.social/issues?state=open&sort=created&direction=desc- Skip malformed issues (no valid
post:orrepost:prefix) - Skip issues matching
./hidden-threads/<user>-<issue_id>
- Check if user has a
- Include the owner's own posts from their
.socialrepository - For reposts/quotes: resolve chain to original post
- Sort by most recent activity (post creation or latest reply)
Extended Content
Posts can have extended content in the Issue body beyond the 250-character title. Clients should:
- Display a "Show more" button when
issue.bodyis non-empty - Lazy-load the full body content when clicked (via
GET /repos/{owner}/.social/issues/{id}) - Support Markdown rendering for extended content
GitHub Actions
Profile Sync (Scheduled: every 30 minutes)
- Fetch the owner's following list via
GET /users/{owner}/following - For each followed user:
- Check if profile cache exists in
./following/<username>.yml - Skip if cache is less than 24 hours old
- Fetch profile via
GET /users/<username> - Check if user has a
.socialrepository - Write profile cache to
./following/<username>.yml
- Check if profile cache exists in
- Remove profile files for users no longer followed
- Commit and push if changes exist
Interaction Contracts
| Action | Mechanism | Validation |
|---|---|---|
| Post | Issue titled post: <text> |
Owner only; ≤250 chars |
| Reply | Comment on Issue | GitHub permissions; ≤250 chars |
| Like | Reaction on Issue/comment | GitHub permissions |
| Follow | GitHub API: PUT /user/following/{username} |
Authenticated user |
| Unfollow | GitHub API: DELETE /user/following/{username} |
Authenticated user |
| Hide User | Add ./hidden/<user> |
Hides user from feed |
| Hide Thread | Add ./hidden-threads/<user>-<issue_id> |
Hides thread from feed |
| Unhide Thread | Remove ./hidden-threads/<user>-<issue_id> |
Shows thread in feed again |
| Mute Thread | Add ./muted/<user>-<issue_id> |
Stop notifications for thread |
| Unmute Thread | Remove ./muted/<user>-<issue_id> |
Resume notifications for thread |
| Close Post | GitHub API: Update issue state: closed |
Owner only |
| Lock Post | GitHub API: Lock issue with reason | Owner only |
| Unlock Post | GitHub API: Unlock issue | Owner only |
| Delete Post | GitHub GraphQL: deleteIssue mutation |
Owner only |
| Repost | Issue titled repost: <url>, empty body |
Valid .social issue URL |
| Quote | Issue titled repost: <url>, body with text |
First 250 chars plain text |
Post Management Actions
Post owners have additional moderation capabilities for their own posts.
Close Post
Closes the Issue, preventing it from appearing in feeds.
| Aspect | Description |
|---|---|
| API | PATCH /repos/{owner}/.social/issues/{id} with state: "closed" |
| Effect on Issue | Issue is closed but still visible on GitHub |
| Effect on Feed | Closed issues are not synced; existing feed files should be removed |
| Reversible | Yes, issue can be reopened |
Lock Post
Locks the Issue, preventing anyone (except the owner) from adding new comments.
| Aspect | Description |
|---|---|
| API | PUT /repos/{owner}/.social/issues/{id}/lock |
| Lock Reasons | off-topic, too heated, resolved, spam, or no reason |
| Effect on Issue | No new comments allowed |
| Effect on Feed | Post remains in feed, replies are frozen |
| Reversible | Yes, issue can be unlocked |
Unlock Post
Unlocks a previously locked Issue, allowing comments again.
| Aspect | Description |
|---|---|
| API | DELETE /repos/{owner}/.social/issues/{id}/lock |
| Effect | Comments are allowed again |
Delete Post
Permanently deletes the Issue. This action cannot be undone.
| Aspect | Description |
|---|---|
| API | GraphQL mutation deleteIssue(input: {issueId: $nodeId}) |
| Effect on Issue | Issue is permanently deleted from GitHub |
| Effect on Feed | Feed file should be immediately removed from feed/ and feed/archive/ |
| Reversible | No - this action is permanent |
Client Behavior for Post Actions
When a client performs Close or Delete actions, it should:
- Execute the GitHub API call to close/delete the Issue
- Delete the corresponding feed file from
feed/<timestamp>-<user>-<issue_id>.md - Also check and delete from
feed/archive/if present - Refresh the feed view to reflect the change
This ensures the post is immediately removed from the user's feed without waiting for the next feed-sync workflow run.
URL Patterns
| Resource | Pattern |
|---|---|
| User profile | https://github.com/<user>/.social |
| Post (Issue) | https://github.com/<user>/.social/issues/<id> |
| Valid repost URL regex | ^https://github\.com/([a-zA-Z0-9-]+)/\.social/issues/(\d+)$ |
TODO
- Rate limit handling: Strategy for users following 100+ accounts. GitHub Actions with PAT allows 5,000 requests/hour. Consider batching, dynamic sync intervals, or pagination limits.
- OctoTown users discovery: Not everyone a user follows on GitHub will have a
.socialrepository. Consider caching which followed users are OctoTown participants.