strangers-js
A CommonJS client library for building bot accounts on Strangers. Login, post, follow, message, and more, without writing raw HTTP calls yourself.
Install
Copy the strangers-js folder into your own project and require it by relative path. No npm registry needed.
const { StrangersBot } = require('./strangers-js');
const bot = new StrangersBot();
Requires Node 18 or newer, since it uses the built in fetch.
Logging in
Two ways to log in: username and password, or a saved session cookie.
// with a username and password
await bot.login('bot_username', 'bot_password');
console.log(bot.user.username);
// with a saved cookie, skips the password entirely
await bot.loginWithCookie(savedToken);
// save the token after logging in once, reuse it next time
const token = bot.getSessionCookie();
// write token somewhere safe, like a local json file
// or pass a cookie straight into the constructor
const bot2 = new StrangersBot({ cookie: token });
Heartbeat
The bot sends a heartbeat every 30 seconds automatically while logged in, so it shows up as online. You never need to call this yourself.
// turn it off if you don't want the bot showing as online
const bot = new StrangersBot({ autoHeartbeat: false });
// or control it by hand
bot.stopHeartbeat();
bot.startHeartbeat();
// change how often it beats, in milliseconds
const bot2 = new StrangersBot({ heartbeatIntervalMs: 15000 });
Watching things
This library never uses a live stream. Instead, watch functions poll in the background and only call you back for things you have not seen yet. A running watcher is also what keeps a persistent bot process alive, the same way a discord bot stays up.
// stop() ends the polling
const stop = bot.notifications.watch((notif) => {
console.log(notif.type, notif.actor.username);
});
// call stop() later if you want to stop watching
stop();
Errors
Every failed request throws a StrangersApiError, with a status code and the server's error message.
const { StrangersApiError } = require('./strangers-js');
try {
await bot.posts.create({ text: '' });
} catch (err) {
if (err instanceof StrangersApiError) {
console.log(err.status, err.message);
console.log(err.data);
}
}
StrangersBot
The main class. Creating one gives you every module below, all sharing the same login session.
new StrangersBot(options)Creates a bot instance. Nothing is sent over the network until you call login, loginWithCookie, or signup.
bot.login(username, password)Logs in with a username and password. Starts the heartbeat automatically. Returns the logged in user, also stored as bot.user.
const user = await bot.login('bot_testing', '12345678');
console.log('logged in as', user.username);
bot.loginWithCookie(cookie)Logs in using a saved session token instead of a password. Starts the heartbeat automatically.
await bot.loginWithCookie('the-saved-token');
bot.signup(data)Creates a brand new account and logs in as it. Starts the heartbeat automatically.
await bot.signup({
username: 'my_new_bot',
password: 'a-strong-password',
inviteKey: 'AB12-CD34-EF56' // only needed if invites are required
});
bot.logout()Logs out and stops the heartbeat.
await bot.logout();
bot.getSessionCookie()Gets the current session token, so you can save it and skip the password next time with loginWithCookie.
const token = bot.getSessionCookie();
bot.stopHeartbeat() / bot.startHeartbeat()Manually stop or start the automatic heartbeat without logging out.
bot.destroy()Stops all timers. Call this if the process is not exiting cleanly on its own.
auth
Lower level account calls. Most of the time bot.login and bot.signup are all you need, this is here for direct access.
bot.auth.signup(data)Same as bot.signup, but does not update bot.user or start the heartbeat.
bot.auth.login(username, password)Same as bot.login, but does not update bot.user or start the heartbeat.
bot.auth.logout()Logs out on the server. Does not clear the local session or stop the heartbeat, use bot.logout for that.
bot.auth.me()Gets the currently logged in account fresh from the server.
const me = await bot.auth.me();
bot.auth.updateMe(data)Updates account settings, like bio or display name.
await bot.auth.updateMe({
bio: 'a friendly bot',
displayName: 'Bot Testing'
});
bot.auth.deleteAccount()Permanently deletes the logged in account. There is no undo.
users
Profiles, following, blocking, and follow requests for private accounts.
bot.users.profile(username)Gets a user's public profile.
const { user } = await bot.users.profile('someusername');
bot.users.posts(username, before)A user's own posts, newest first. Pass the createdAt of the oldest post you already have as before, to load older ones.
bot.users.replies(username, before)A user's replies to other posts.
bot.users.relays(username, before)Posts a user has relayed.
bot.users.followers(username)Who follows this user.
bot.users.following(username)Who this user follows.
bot.users.follow(username)Follows a user. If their account is private, this creates a pending follow request instead.
await bot.users.follow('someusername');
bot.users.unfollow(username)Unfollows a user.
bot.users.block(username)Blocks a user. Cuts off following, messaging, and visibility in both directions.
bot.users.unblock(username)Unblocks a user.
bot.users.blockedUsers()Everyone the logged in account has blocked.
bot.users.report(username, reason)Reports a user for moderation. Returns { ok: true }.
bot.users.followRequests()Pending follow requests this account has received, only relevant for private accounts.
bot.users.acceptFollowRequest(username)Accepts a pending follow request.
bot.users.rejectFollowRequest(username)Rejects a pending follow request.
posts
The feed, creating posts, and every reaction you can take on one.
bot.posts.feed(before)The main feed. Pass before to page further back.
const { posts } = await bot.posts.feed();
bot.posts.create(data)Creates a post.
await bot.posts.create({ text: 'hello world' });
// a reply
await bot.posts.create({ text: 'nice post', replyToId: postId });
// a poll
await bot.posts.create({
text: 'cats or dogs?',
poll: {
durationHours: 24,
options: [{ text: 'cats' }, { text: 'dogs' }]
}
});
bot.posts.get(postId)Gets one post and its reply thread.
bot.posts.remove(postId)Deletes a post the bot authored.
bot.posts.pin(postId)Pins a post to the top of the bot's profile.
bot.posts.unpin(postId)Unpins a post.
bot.posts.like(postId)Likes a post.
bot.posts.unlike(postId)Removes a like.
bot.posts.relay(postId, quoteText)Relays a post. Pass quoteText to make it a quote relay instead of a plain one.
await bot.posts.relay(postId);
await bot.posts.relay(postId, 'this is great');
bot.posts.unrelay(postId)Removes a relay.
bot.posts.trackView(postId)Marks a post as viewed, for its view count.
bot.posts.votePoll(postId, optionId)Votes on a poll. optionId comes from the post's poll.options list.
bot.posts.report(postId, reason)Reports a post for moderation. Returns { ok: true }.
bookmarks
Posts the bot has privately saved.
bot.bookmarks.list(before)The bot's own bookmarks, newest saved first.
Returns { posts, hasMore }. Each post has the same shape a regular post has everywhere else in this API, plus two bookmark-specific fields always set on every item here:
{
"posts": [
{
// same fields as a regular post duh
"bookmarked": true,
"bookmarkedAt": "2026-09-07T14:32:10.000Z"
}
],
"hasMore": false
}
bot.bookmarks.add(postId)Bookmarks a post. Returns { bookmarked: true }. Safe to call twice on the same post: a repeat call is a no-op, not an error.
bot.bookmarks.remove(postId)Removes a bookmark. Returns { bookmarked: false }.
notifications
Likes, follows, replies, mentions, and message activity aimed at the bot.
bot.notifications.list()The bot's notifications, newest first, plus an unread count.
bot.notifications.markRead()Marks every notification as read.
bot.notifications.watch(onNotification, options)Polls in the background and calls onNotification once for each new notification. Keeps the process running until you call the returned stop function.
const { NotificationType } = require('./strangers-js');
const stop = bot.notifications.watch((notif) => {
console.log(notif.actor.username, 'followed you');
}, { types: [NotificationType.Follow] });
The notif object passed to onNotification, same shape returned by list()'s notifications array, and by each event on the SSE stream the frontend uses:
// example notif for a like
{
"id": "a1b2c3d4-...",
"type": "like",
"createdAt": "2026-09-07T14:32:10.000Z",
"read": false,
"actor": {
"username": "someusername",
"displayName": "Some User",
"avatarUrl": "https://files.shibbystudios.xyz/..."
},
"postId": "e5f6...",
"postPreview": "the first 80 characters of the liked post"
}
postId/postPreview are only populated for post-related types: like, relay, reply, mention_post. For follow, mention_bio, and any message-related notification, both are null.
presence
The heartbeat itself is automatic, this module is just for reading online status.
bot.presence.onlineCount()How many accounts are online right now, site wide. Returns { online }, a plain count, deduped so one account open in several tabs still only counts once.
search
bot.search.search(q, options)Searches users, hashtags, and posts. Prefix q with @ for users only, or # for a hashtag only.
const results = await bot.search.search('cats');
const onlyUsers = await bot.search.search('@someusername');
Always returns { users, hashtags, posts }. Whichever of the three don't apply to your query (e.g. posts for an @-prefixed search) just come back as empty arrays, rather than being left out. With quick: true, posts is always [], since quick search never runs the post query at all; that's what makes it fast.
{
"users": [
{
"id": "7c1a...",
"username": "someusername",
"displayName": "Some User",
"bio": "...",
"avatarUrl": "https://files.shibbystudios.xyz/...",
"bannerUrl": null,
"isPrivate": false,
"isVerified": false,
"isPremium": false,
"isAdultOnly": false
}
],
"hashtags": ["catsofstrangers", "catpics"],
"posts": []
}
bot.search.trendingHashtags()Currently trending hashtags. Returns { hashtags }, where each entry is { tag, postCount }. Unlike search results, these come back as objects (with a count), not plain strings.
{
"hashtags": [
{ "tag": "catsofstrangers", "postCount": 142 },
{ "tag": "mcm", "postCount": 98 }
]
}
bot.search.hashtagPosts(tag)Posts carrying a given hashtag, without the leading #. Returns { posts }.
media
bot.media.upload(buffer, contentType)Uploads an image and returns its hosted url, ready to use as a post's imageUrl.
const fs = require('fs');
const buffer = fs.readFileSync('./photo.png');
const { url } = await bot.media.upload(buffer, 'image/png');
await bot.posts.create({ text: 'check this out', imageUrl: url });
Returns just { url }, nothing else. That url is what you hand back in as imageUrl for a post, poll option, blog cover, or profile avatar/banner; the server rejects any image field that isn't a URL it issued itself.
bot.media.youtubeMeta(videoId)Looks up title, author, and thumbnail for a YouTube video id.
{
"title": "Some Video Title",
"author": "Some Channel",
"thumbnailUrl": "https://i.ytimg.com/vi/.../hqdefault.jpg"
}
messages
Direct messages between accounts.
bot.messages.conversations()Every conversation the bot is part of, including its own pending sent requests. Returns { conversations }, each shaped:
{
"conversations": [
{
"id": "9f2e...",
"status": "accepted",
"otherUser": {
"id": "7c1a...",
"username": "someusername",
"displayName": "Some User",
"bio": "...",
"avatarUrl": "https://files.shibbystudios.xyz/...",
"bannerUrl": null,
"isPrivate": false,
"isVerified": false,
"isPremium": false,
"isAdultOnly": false
},
"lastMessage": { "text": "hey there", "senderId": "7c1a..." },
"lastMessageAt": "2026-09-07T14:32:10.000Z",
"unreadCount": 1,
"isRequester": false
}
]
}
bot.messages.requests()Message requests other people sent to the bot, waiting to be accepted. Returns { requests }, same shape as a conversation above, minus isRequester (every row here was, by definition, sent by someone else).
bot.messages.getConversation(conversationId)A single conversation's full message history. Returns { conversation, messages }:
{
"conversation": {
"id": "9f2e...",
"status": "accepted",
"isRequester": false,
"otherUser": { // same shape as conversations() above }
},
"messages": [
{
"id": "a4b2...",
"text": "hey there",
"senderId": "7c1a...",
"createdAt": "2026-09-07T14:32:10.000Z",
"read": true
}
]
}
bot.messages.send(username, text)Sends a message to a user by username. Creates a new conversation if one does not exist yet. If the recipient is the one replying to the bot's still-pending request, that reply auto-accepts it.
await bot.messages.send('someusername', 'hey there');
Returns { conversation: { id, status }, message: { id, text, senderId, createdAt, read } }. message.read is always false here, since it was just sent.
{
"conversation": { "id": "9f2e...", "status": "pending" },
"message": {
"id": "a4b2...",
"text": "hey there",
"senderId": "7c1a...",
"createdAt": "2026-09-07T14:32:10.000Z",
"read": false
}
}
bot.messages.accept(conversationId)Accepts a pending message request. Returns { ok: true }.
bot.messages.remove(conversationId)Deletes or denies a conversation. Returns { ok: true }.
bot.messages.watch(conversationId, onMessage, intervalMs)Polls one conversation in the background and calls onMessage for each new message.
const stop = bot.messages.watch(conversationId, (message) => {
console.log(message.text);
});
blog
Reading the official Strangers blog. Read only, no bot ever writes to this.
bot.blog.list(before)Published blog posts, newest first. Returns { posts, hasMore }, each post shaped:
{
"posts": [
{
"id": "3d9c...",
"title": "What's new this month",
"body": "## Heading\n\nSome **markdown** text.",
"imageUrl": null,
"createdAt": "2026-09-01T12:00:00.000Z",
"updatedAt": "2026-09-01T12:00:00.000Z",
"author": { "username": "admin", "displayName": "Strangers Team", "avatarUrl": "https://files.shibbystudios.xyz/..." }
}
],
"hasMore": false
}
bot.blog.get(postId)A single blog post. Returns { post }, same shape as one entry from list() above.
cssLibrary
Browsing and sharing profile CSS templates. Submitting requires the account to be Premium.
bot.cssLibrary.list(sort, before)sort can be "popular" or "recent". Returns { items, hasMore }. hasMore is only ever true for "recent"; "popular" always returns its full ranked window in one response, there's no next page. Each item:
{
"items": [
{
"id": "e1f0...",
"title": "Dark mode retro",
"description": "",
"imageUrl": "https://files.shibbystudios.xyz/...",
"likes": 12,
"liked": false,
"createdAt": "2026-08-30T09:00:00.000Z",
"author": { "username": "someusername", "displayName": "Some User", "avatarUrl": "...", "isVerified": false },
"isOwn": false
}
],
"hasMore": true
}
bot.cssLibrary.get(itemId)A single template, including the full CSS. Returns { item }, same shape as list() above, but with a css field included this time (the actual CSS text).
{
"item": {
// same fields as list()
"css": "body { background: #14181a; }"
}
}
bot.cssLibrary.submit(data)Shares a template. data can include title, description, css, and imageUrl. Returns { item }, same full shape as get() above (css included).
bot.cssLibrary.remove(itemId)Removes a template the bot submitted. Returns { ok: true }.
bot.cssLibrary.like(itemId)Likes a template. Returns { liked: true }.
bot.cssLibrary.unlike(itemId)Removes a like. Returns { liked: false }.
gifs
bot.gifs.search(q, page)Searches for GIFs. Returns { gifs, ads, page, hasMore }:
{
"gifs": [
{
"id": "482910",
"title": "",
"url": "https://static.klipy.com/.../full.gif",
"width": 480,
"height": 270,
"previewUrl": "https://static.klipy.com/.../preview.gif"
}
],
"ads": [
{ "id": "ad-4", "isAd": true, "htmlContent": "<html>...</html>", "width": 320, "height": 50 }
],
"page": 1,
"hasMore": true
}
bot.gifs.trending(page)Currently trending GIFs. Returns the same { gifs, ads, page, hasMore } shape as search() above.
config
bot.config.get()Public instance config, including whether invite keys are required to sign up. Returns { inviteKeysEnabled, featureFlags }. featureFlags is the same object featureFlags() below returns, folded in here so a caller that fetches this once at startup doesn't need a second request.
{
"inviteKeysEnabled": true,
"featureFlags": {
"messages": true,
"blog": true,
"bookmarks": true,
"css_library": true,
"gifs": true,
"polls": true,
"premium": true,
"presence": true,
"search": true
}
}
bot.config.featureFlags()Which optional features are currently turned on. Returns { featureFlags }, a plain { [key]: boolean } map. Keys currently include messages, blog, bookmarks, css_library, gifs, polls, premium, presence, search. A key that's missing entirely (rather than false) hasn't happened yet in practice, but should be treated as enabled if it ever does.
{ "featureFlags": { "messages": true, "gifs": false, /* ... */ } }
warnings
Moderation warnings issued to this account.
bot.warnings.current()The oldest unacknowledged warning, or null if there is none. Returns { warning }, where warning is { id, reason, createdAt } or null. If the account has more than one outstanding warning, this always returns the oldest first; you have to acknowledge it before the next one shows up here.
{ "warning": { "id": "c8e1...", "reason": "Spam", "createdAt": "2026-09-05T10:00:00.000Z" } }
bot.warnings.acknowledge(warningId)Acknowledges a warning so it stops being returned by current(). Returns { ok: true }.
NotificationType
Constants for every notification type, so you never have to type a raw string. Every type shares the same notification object shape, only postId/postPreview vary by type.
const { NotificationType } = require('./strangers-js');
| Constant | Value | Fires when |
|---|---|---|
| NotificationType.Follow | follow | someone follows the bot |
| NotificationType.Like | like | someone likes the bot's post |
| NotificationType.Relay | relay | someone relays the bot's post |
| NotificationType.Reply | reply | someone replies to the bot's post |
| NotificationType.MentionPost | mention_post | someone @mentions the bot in a post |
| NotificationType.MentionBio | mention_bio | someone @mentions the bot in their bio |
| NotificationType.MessageRequest | message_request | someone sends the bot a first message |
| NotificationType.Message | message | a new message in an existing conversation |
| NotificationType.MessageAccept | message_accept | someone accepts the bot's message request |
bot.notifications.watch((notif) => {
switch (notif.type) {
case NotificationType.Follow:
console.log(notif.actor.username + ' followed you');
break;
case NotificationType.Like:
console.log(notif.actor.username + ' liked your post');
break;
default:
console.log('other notification:', notif.type);
}
});
Full example
A persistent bot that stays running, follows back anyone who follows it, and replies to direct messages.
const { StrangersBot, NotificationType } = require('./strangers-js');
const bot = new StrangersBot();
async function main() {
await bot.login('bot_testing', '12345678');
console.log('logged in as', bot.user.username);
// follow back, keeps the process alive on its own
bot.notifications.watch((notif) => {
if (notif.type === NotificationType.Follow) {
bot.users.follow(notif.actor.username);
}
});
// reply to dms
const watching = new Set();
setInterval(async () => {
const { conversations } = await bot.messages.conversations();
for (const convo of conversations) {
if (watching.has(convo.id)) continue;
watching.add(convo.id);
bot.messages.watch(convo.id, (message) => {
if (message.senderId === bot.user.id) return;
bot.messages.send(convo.id, 'thanks for the message!');
});
}
}, 15000);
console.log('bot is running, press ctrl+c to stop');
}
process.on('SIGINT', async () => {
await bot.logout();
process.exit(0);
});
main().catch((err) => {
console.error('bot crashed:', err);
});