Custom feeds based on your mood for Bluesky
README.md

moods #

A Bluesky bot that turns a stated mood into a custom feed built from the requester's own social graph. See ARCHITECTURE.md.

Layout #

  • src/, test/: the Gleam app. dev/: development tools, such as graph_report (just graph <handle>).
  • ui/: the website's Lustre code that runs on both sides: the server renders it, and its JavaScript build hydrates the pages. ui/app.css is the Tailwind entry. just ui builds both into priv/static/.
  • sidecar/embedder.py: the embedding sidecar (Python, fastembed). It only computes vectors; the app decides what to embed and stores them.
  • ARCHITECTURE.md: architecture, cost model, and open questions.
  • DESIGN.md: the website's visual design (maintained with the impeccable skill; its working files in .impeccable/ are mostly untracked).
  • scoping/: the experiments behind the design: classifier benchmarks (perf.txt), firehose captures, social-graph ranking (pool.py), and openjev-sglang, a self-hosted classifier on Modal. Large data files there are not committed.

Development #

Tools come from devenv: Gleam, Erlang/OTP 29, SQLite, esbuild, Tailwind CSS v4, websocat, uv, Colmena, nixos-anywhere, just, and the bug, lcr, and secrets tools. Dev state lives in data/dev/ (devenv sets MOODS_POSTS_DATABASE and MOODS_DATABASE).

devenv shell       # enter the environment
just dev           # Nix-built sidecar in the background, then `gleam run` from source
devenv up          # the Nix builds of everything (lcr, sidecar, service), as on a host
just test          # format check and tests
just web           # the website alone, from source (MOODS_PORT=7172 while devenv up holds 7072)
just graph rtw.run # an account's social graph and the cached posts it collects
just wholesome     # score scoping/wholesome.txt with the homepage's wholesome check
just forget <handle> <host>          # delete an account's data on a host, after confirming
just mood "gimme food pics"          # rewrite a mood, rank rtw.run's graph, classify the top 30
watchexec -e gleam -- gleam test   # rerun tests on change

Don't run just dev and devenv up's service at the same time: they'd both ingest into the same database.

To try requests locally, set MOODS_MAIN_DID to the test account ($MOODS_EVAL_DID) and export the Anthropic and Cloudflare keys, then mention the test account from another account. Don't use the main account's DID while a host is running: both would handle every request.

Configuration #

Environment variables, all optional:

Variable Default
MOODS_POSTS_DATABASE posts.db Post cache and other rebuildable data; not backed up
MOODS_DATABASE moods.db App state (moods, feeds); backed up with Litestream
MOODS_JETSTREAM_HOST jetstream.us-east.bsky.network Jetstream v2 instance
MOODS_RETENTION_HOURS 48 How long posts stay cached
MOODS_BACKFILL_HOURS 35 History to replay on first start (the live tail reaches back 36)
MOODS_PORT 7072 Localhost port for the website and feed endpoints
MOODS_FEEDS_PER_DAY 250 Feed builds allowed from everyone in the last 24 hours (an explored feed counts 2); past it, requests are told the service is at capacity
MOODS_FEEDS_PER_REQUESTER_HOUR 3 Feed builds allowed from one account in the last hour. Accounts in priv/rate-limit-exempt.txt are exempt from both
MOODS_FEED_HOSTNAME Public hostname of the feed service (did:web:<hostname>); without it, only the website is served
MOODS_EMBEDDER_URL http://127.0.0.1:7071 Embedding sidecar
MOODS_ANTHROPIC_API_KEY_FILE File holding the Anthropic API key, for normalizing moods (hosts: from the secrets store)
ANTHROPIC_API_KEY The key itself, when there's no file (just mood reads anthropic/api_key from the secrets store)
MOODS_CLOUDFLARE_ACCOUNT_ID_FILE, MOODS_CLOUDFLARE_AI_API_TOKEN_FILE Files holding the Workers AI credentials for System One (hosts: from the secrets store); else CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AI_API_TOKEN
MOODS_MAIN_DID_FILE File holding the main account's DID (@mymood.at; hosts: moods/identities/main_account_did via the secrets store); else MOODS_MAIN_DID. Posts that begin by mentioning it are requests. Unset, mentions are ignored; set, but without the Anthropic and Cloudflare credentials, they're queued for up to the retention window
MOODS_MAIN_APP_PASSWORD_FILE File holding the main account's app password (hosts: moods/identities/main_app_password); else MOODS_MAIN_APP_PASSWORD. Publishes feed records. Without it, or without MOODS_FEED_HOSTNAME, allowed moods wait in moods.db
MOODS_BOT_DID_FILE, MOODS_BOT_APP_PASSWORD_FILE The bot account (@bot.mymood.at), which replies with feed links (hosts: moods/identities/bot_*); else MOODS_BOT_DID, MOODS_BOT_APP_PASSWORD
MOODS_BADBOT_DID_FILE, MOODS_BADBOT_APP_PASSWORD_FILE The bad bot account (@badbot.mymood.at), which refuses (hosts: moods/identities/badbot_*); else MOODS_BADBOT_DID, MOODS_BADBOT_APP_PASSWORD. Without both bots, replies wait in moods.db

The sidecar reads EMBEDDER_HOST, EMBEDDER_PORT, EMBEDDER_MODEL (default BAAI/bge-small-en-v1.5), EMBEDDER_MODEL_PATH (model files to load instead of downloading; the Nix builds set it) and FASTEMBED_CACHE_PATH. Changing the model re-embeds every cached post.

posts.db is a disposable cache with a schema version: after a schema change it's rebuilt and refilled from Jetstream on the next start.

Deployment #

NixOS on Hetzner Cloud or netcup, installed with nixos-anywhere and deployed with Colmena; see nix/README.md. In short:

just new-host <ipv4> <fqdn> [hetzner|netcup]  # install NixOS on a fresh box (erases it)
just deploy <fqdn>            # build, copy, upload secrets, switch
just check                    # VM test of the production units
just update-deps-hash         # after changing Gleam dependencies

devenv up runs the same Nix builds the hosts run, with state in data/dev/.

possum #

The app depends on a fork of possum (branch jetstream, pinned in manifest.toml) for Jetstream v2 and DAG-CBOR support, pending upstream review. gleam update possum moves to the branch's latest commit. To develop against a local checkout, change the dependency in gleam.toml to { path = "../possum" }.

Testing classification on Modal #

cd scoping/openjev-sglang
uv sync
uv run modal deploy modal_app.py   # prints the endpoint URL

Then from scoping/, run BACKEND=openjev OPENJEV_URL=... ./filter.sh "<mood>" or OPENJEV_URL=... uv run bench.py ....

Benchmarking Clef #

scoping/bench.py reads the Workers AI credentials from the secrets store (cloudflare/account_id, cloudflare/ai_api_token). From scoping/, after ./prefilter.sh:

uv run bench.py --backend clef --criteria --posts 400 --concurrency 32,64 \
  "The user wants to see posts about adventure motorcycling."

--model clef-flash tries the cheaper model. just mood "<mood>" [top] [handle] runs the whole job on a real graph: the rewrite, the top 30 (or top) posts by embedding rank, and the ones Clef passed.