- Gleam 84%
- Nix 5%
- CSS 5%
- Python 4%
- HTML 1%
- 2 more languages 1%
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 asgraph_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.cssis the Tailwind entry.just uibuilds both intopriv/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), andopenjev-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.