Notifications channel for Bluesky for Letta Code-based agents
README.md

letta-bluesky-channel #

A Bluesky / AT Protocol channel plugin for Letta Code with agent-controlled attention management.

The plugin polls AT Protocol notifications and routes them through an event pipeline the agent can reprogram at runtime. The agent creates rules that boost or suppress events by thread, sender, or keyword, and defines named queues with custom emission policies — batching events by count, age, or conversation idle time.

The salience scoring is inspired by Myelin's event pipeline: sender tier × kind multiplier + keyword/hot-topic boosts, extended with a dynamic rules layer. No LLM in the hot path — pure math, fast, inspectable.

No npm packages required. Pure fetch + Node built-ins.

Requires Letta Code ≥ 0.24.x for user plugin support.


Architecture #

AT Protocol
    ├─ listNotifications (poll every 2m)
    ├─ getTimeline (poll per config)
    └─ getFeed (poll per config)
    │
    ▼
 Normalize ──→ internal event shape (origin: notification | feed)
    │
    ▼
 Enrich ──→ sender tier, keyword hits, hot topic hits
    │
    ▼
 Assign post ref ──→ hex ID [001]–[fff] from ring buffer
    │
    ▼
 Base salience score (feed unknowns start at 0)
    │
    ▼
 Apply dynamic rules ──→ multiplicative layer (boost/suppress with decay)
    │
    ▼
 Route
    ├─ notification + salience ≥ threshold ──→ emit immediately (chatId: "notifications")
    ├─ salience ≥ floor ──→ best-matching named queue ──→ emit when policy fires
    └─ salience < floor ──→ drop

When the agent posts to a thread, the plugin automatically creates a boost rule and a transient tracking queue for that thread. The queue uses idle emission — it fires when the thread goes quiet, delivering the full conversation as a coherent unit.


Installation #

1. Copy files into Letta's channel directory #

cp -r bluesky-channel ~/.letta/channels/bluesky

The directory must be named bluesky — it must match the id in channel.json.

2. Add your credentials #

cp ~/.letta/channels/bluesky/accounts.json.example ~/.letta/channels/bluesky/accounts.json
nano ~/.letta/channels/bluesky/accounts.json

Fill in identifier, password (use an app password, not your login password), and pds (use https://bsky.social unless you're on a custom PDS).

3. Get your agent ID #

letta agent list

4. Set up routes #

Do not use letta channels configure bluesky — that command only supports first-party channels. Custom plugins are configured via accounts.json and route add.

letta channels route add --channel bluesky --chat-id notifications --agent <YOUR_AGENT_ID> --conversation default
letta channels route add --channel bluesky --chat-id digest --agent <YOUR_AGENT_ID> --conversation default
letta channels route add --channel bluesky --chat-id feed --agent <YOUR_AGENT_ID> --conversation default
letta channels route add --channel bluesky --chat-id control --agent <YOUR_AGENT_ID> --conversation default
letta channels route add --channel bluesky --chat-id dm --agent <YOUR_AGENT_ID> --conversation default

The dm route is required for direct messages. All Bluesky DMs flow through the single dm chatId (the sender is identified inside the message). See Direct messages — and note that DMs also need a DM-enabled app password.

The control route is required — without it, Letta won't let the agent send messages to the control channel. Control commands are intercepted by the plugin and never become AT Protocol posts, but Letta still needs a valid route for the chatId.

If you create custom queues that emit to other chatIds, add routes for those too:

letta channels route add --channel bluesky --chat-id queue:my-queue --agent <YOUR_AGENT_ID> --conversation default

5. Restart Letta #

systemctl restart letta-agent  # or however you run it

You should see in the logs:

[Bluesky] Starting for yourhandle.bsky.social
[Bluesky] Poll: 120s, emission check: 30s
[Bluesky] Threshold: 0.35, floor: 0.05
[Bluesky] Rules: 0 active, queues: 2
[Bluesky] Authenticated as did:plc:... (yourhandle.bsky.social)

Files #

All runtime files are co-located with plugin.mjs (PLUGIN_DIR), which is ~/.letta/channels/bluesky/ at runtime.

File Managed by Description
rules.json Operator Static config: thresholds, tiers, keywords, auto-boost settings
dynamic-rules.json Agent Dynamic boost/suppress rules created via control channel
queues.json Agent + system Named queue definitions and their buffers
state.json System Dedup cursor + post ring buffer (hex ref → URI/CID mapping)
accounts.json Operator Credentials

rules.json is re-read every poll cycle — changes take effect within 2 minutes, no restart needed. dynamic-rules.json and queues.json are also hot-reloaded.

Poll and emission check intervals are read once at startup and require a restart to change.


Configuration (rules.json) #

Core #

Field Default Description
salience_threshold 0.35 Minimum score to emit an alert immediately
salience_floor 0.05 Minimum score to queue (below this = drop)
alert_poll_interval_ms 120000 Notification poll interval. Restart to change.
emission_check_interval_ms 30000 Queue emission check interval. Restart to change.
langs ["en"] Language tags on outbound posts

Direct messages #

DMs are handled separately from the salience pipeline — see Direct messages for the full picture. Requires a DM-enabled app password.

Field Default Description
dm_enabled "auto" true / false / "auto" (probe chat access at startup, disable on token-scope error)
chat_poll_interval_ms 10000 DM (getLog) poll interval. Restart to change.
dm_idle_seconds 12 Per-conversation debounce — emit a turn once the convo is quiet this long
dm_max_wait_seconds 90 Hard cap on how long a turn can buffer before forced delivery

Salience inputs #

Field Default Description
entity_tiers {} DID → tier (1/2/3). Higher tier = higher base salience.
keywords [] Keywords that boost salience (+0.15 each, max +0.40)
hot_topics [] Topics that strongly boost salience (+0.30 each)

Auto-boost #

When the agent posts to a thread, the plugin automatically creates a boost rule and a tracking queue for that thread.

Field Default Description
auto_boost_on_post true Enable auto-boost on outbound posts
auto_boost_multiplier 2.5 Boost multiplier for auto-rules
auto_boost_ttl_seconds 7200 Auto-rule TTL (2 hours)
auto_boost_decay "exponential" Decay function for auto-rules
auto_queue_idle_minutes 10 Idle time before auto-queue emits

Feeds #

Feed polling is opt-in. Add feed sources to the feeds array to enable. Each feed gets its own poll timer.

Field Required Description
id Yes Unique identifier for this feed (e.g., "following", "for-you")
type Yes "timeline" for Following feed, "generator"` for custom feeds
uri For generators AT URI of the feed generator (e.g., "at://did:.../app.bsky.feed.generator/for-you")
pollIntervalMs No Poll interval in ms (default: 300000 / 5 minutes)

When a timeline feed is enabled, replies are filtered to match Bluesky web behavior: a reply is only surfaced if the root author is someone you follow (or yourself). Set feed_reply_filter: false to receive all timeline replies.

Field Default Description
feed_reply_filter true Filter timeline replies by thread-root author (followed or self)

Example:

{
  "feeds": [
    {
      "id": "following",
      "type": "timeline",
      "pollIntervalMs": 300000
    },
    {
      "id": "for-you",
      "type": "generator",
      "uri": "at://did:plc:.../app.bsky.feed.generator/for-you",
      "pollIntervalMs": 600000
    }
  ]
}

Feed posts enter the same scoring pipeline as notifications but with key differences:

  • Unknown senders score 0 (not 0.05). A feed post must earn its score through entity tiers, keywords, or hot topics.
  • Feed posts never emit as immediate alerts. They always route to queues, regardless of score. Feeds are peripheral vision — the agent scrolls, not the other way around.
  • Cross-source dedup. If a post appears as both a notification and a feed item, the notification wins.

The default feed-digest queue collects feed highlights and emits to chatId: "feed" (add a route for this). Agents can create feed-specific queues matching on origins: ["feed"] or feedIds: ["following"].

On first poll, the feed cursor is set without processing — the agent starts observing from "now," not from the beginning of the feed.


Salience scoring #

base = (tier_base + keyword_boost + hot_topic_boost) × kind_multiplier
final = base × dynamic_rule_multipliers

Base score components #

Component Value
Tier 1 sender 0.60
Tier 2 sender 0.30
Tier 3 sender 0.10
Unknown sender (notification) 0.05
Unknown sender (feed) 0.00
Per keyword hit +0.15 (max +0.40 total)
Per hot topic hit +0.30

Kind multipliers #

Kind Multiplier Source
mention ×1.5 notification
quote ×1.4 notification
reply ×1.3 notification
post ×1.0 feed
repost ×0.5 both
follow ×0.4 notification
like ×0.3 notification
starterpack-joined ×0.3 notification

Base score is capped at 1.0, then dynamic rules apply as a multiplicative layer (also capped at 1.0).

Example: a tier-2 sender replying to you scores 0.30 × 1.3 = 0.39. With the default threshold of 0.35, this emits immediately. If the agent has a suppress_entity rule (×0.3) on this sender, the final score drops to 0.39 × 0.3 = 0.12 — queued instead.


Dynamic rules #

Rules are the agent's way of reprogramming its own attention. Created via the control channel, they modify salience as a multiplicative layer after base scoring.

Rule kinds #

Kind Default multiplier Default decay Typical use
boost_thread ×3.0 linear Watch a thread for replies
boost_entity ×2.0 linear Track an interesting account
boost_keyword ×2.5 linear Watch for a developing topic
suppress_thread ×0.2 none Mute a noisy thread
suppress_entity ×0.3 none Quiet a spammy sender
suppress_keyword ×0.2 none Filter out a topic

Decay functions #

Rules don't just expire — they fade. The effective multiplier moves toward 1.0 (neutral) over the rule's lifetime.

  • none — constant until expiry. Good for suppression.
  • linear — effective = multiplier + (1.0 - multiplier) × (elapsed / ttl). A ×3.0 boost halfway through is effectively ×2.0.
  • exponential — half-life at TTL/3. Strong initially, fading fast. Good for thread tracking after posting.

Hard limits #

Limit Value
Max active rules 20
Max boost multiplier 5.0
Min suppress multiplier 0.1 (can't fully mute)
Max TTL 24 hours
Max rules per hour 10

Targeting #

A rule matches an event when ALL non-null target fields match. A rule targeting only threadId matches all events in that thread. A rule targeting sender + eventKind matches that sender's events of that kind in any thread.


Named queues #

Events between the alert threshold and the floor are routed to named queues. Each queue has match criteria and an emission policy that controls when buffered events are delivered to the agent.

Default queues #

The plugin ships with three queues that provide sensible defaults:

  • digest — notification likes, reposts, follows. Emits at 10 items or 60 minutes. chatId: "digest", flat format.
  • below-threshold — notification replies, quotes, mentions that scored below the alert threshold. Emits at 5 items or 30 minutes. chatId: "digest", grouped format.
  • feed-digest — feed posts that scored above the floor. Emits at 5 items or 2 hours. chatId: "feed", flat format. Only active when feeds are configured.

Emission policies #

Policy Fires when Good for
count N items accumulate Fixed batch sizes
age Oldest item exceeds threshold Freshness guarantee
averageAge Mean age of buffer exceeds threshold Steady-rate streams
idle No new items for N minutes Conversation tracking — fires when a thread goes quiet
manual Agent explicitly requests flush On-demand inspection
age+count Either age OR count fires Pragmatic default

The idle policy is the most agent-native. It answers: "when is this conversation done talking so I can read it as a unit?" Pair it with fetchThread: true to get the full thread (including the agent's own posts) on emission.

Queue fields #

{
  "id": "my-queue",
  "displayName": "My Queue",
  "chatId": "queue:my-queue",
  "match": {
    "origins": ["notification"],
    "feedIds": ["following"],
    "kinds": ["reply", "quote"],
    "threadIds": ["at://..."],
    "senders": ["alice.bsky.social"],
    "minSalience": 0.0,
    "maxSalience": 1.0
  },
  "emit": {
    "policy": "idle",
    "countThreshold": 10,
    "maxAgeMinutes": 60,
    "avgAgeMinutes": 45,
    "idleMinutes": 10,
    "maxWaitMinutes": 60
  },
  "format": "grouped",
  "fetchThread": true,
  "transient": false
}

All match fields are optional — omitted fields match everything. All emit fields have defaults; only the ones relevant to the chosen policy matter.

Format modes #

flat — one line per item, chronological:

📦 Activity digest — 5 items

• [04b] @alice liked (3h ago): "post text..."
• [04c] @bob followed you (45m ago)
• [04d]🧵 @carol reposted (just now): "thread post..."

grouped — events grouped by thread, with full thread context if fetchThread is enabled:

📦 Thread tracker — 4 items

┌ thread: at://did:.../app.bsky.feed.post/root
│ [04e] 🤖 you (25m ago): "Here's what I think about..."
│ [04f] @alice (8m ago): "I think the approach works..."
│ [050] @bob (6m ago): "What about edge cases?"
│ [051] @alice (3m ago): "Good point, let me think..."
└ 3 new events

summary — compressed counts with top senders:

📊 Social — 14 items
12 likes, 2 reposts (top: @alice ×3, @bob ×2)

Queue matching priority #

When multiple queues match an event: most specific wins (more non-null match fields). On tie, the queue with fewer buffered items wins.

Transient queues #

Queues created by auto-boost (or manually with "transient": true) are garbage collected when their associated rules expire and their buffer is empty.


Post references #

Every post emitted by the plugin — whether in alerts, queue emissions, or fetched threads — is assigned a short hex ID from a ring buffer. These IDs appear in square brackets at the start of each post line:

📨 [04a]🧵 @alice.bsky.social replied — 2026-05-28T09:30:00Z (5m ago)
  • [04a] — the hex reference ID, wrapping from 001 through fff (4095 slots)
  • 🧵 — thread indicator, shown when a post is part of a thread (has a parent/root)

The agent uses these refs instead of AT Protocol URIs for all interactions:

Action How
Reply Pass the hex ref as replyToMessageId (e.g., "04a") — resolved to AT URI automatically
Like Control channel: {"op": "fav", "ref": "04a"}
Repost Control channel: {"op": "repost", "ref": "04a"}
Get details Control channel: {"op": "detail", "ref": "04a"} — returns full URI, CID, sender, text

The ring buffer persists in state.json. When it fills up, old entries are overwritten — this is fine because refs are short-lived by design, matching the agent's context window lifetime.

Inspired by TTYtter's hex reference system — the same ergonomic shorthand that made a text-mode Twitter client feel fast, applied to agent-facing tool use.


Control channel #

The agent manages rules and queues by sending JSON commands to chatId: "control" via the MessageChannel tool. Commands never become AT Protocol posts — they're intercepted by the plugin.

Responses are returned as the tool result.

Rule commands #

{"op": "boost_thread", "threadId": "at://...", "multiplier": 3.0, "ttl": "2h", "decay": "exponential", "reason": "watching for replies"}
{"op": "boost_entity", "sender": "interesting.bsky.social", "multiplier": 2.0, "ttl": "6h", "reason": "good conversation"}
{"op": "boost_keyword", "keyword": "atproto", "multiplier": 2.5, "ttl": "4h", "reason": "topic developing"}
{"op": "suppress_thread", "threadId": "at://...", "multiplier": 0.2, "ttl": "4h", "reason": "noisy thread"}
{"op": "suppress_entity", "sender": "spammy.bsky.social", "ttl": "6h", "reason": "low signal"}
{"op": "remove_rule", "ruleId": "r-..."}

TTL accepts human-readable strings: "30m", "2h", "6h", "1d". All fields except op and the targeting field are optional with sensible defaults.

Queue commands #

{"op": "create_queue", "id": "security", "match": {"kinds": ["reply", "mention"], "senders": ["securitybot.bsky.social"]}, "emit": {"policy": "count", "countThreshold": 3}, "format": "grouped", "chatId": "queue:security"}
{"op": "modify_queue", "id": "security", "emit": {"countThreshold": 5}}
{"op": "flush_queue", "id": "security"}
{"op": "delete_queue", "id": "security"}

Post interactions #

{"op": "fav", "ref": "04a"}
{"op": "repost", "ref": "04a"}
{"op": "detail", "ref": "04a"}

detail returns the full post data: sender, DID, kind, timestamp, AT URI, CID, thread URI, and full text. Useful when the agent wants to inspect a post before interacting.

fav (or like) and repost execute immediately against the AT Protocol API.

Introspection #

{"op": "status"}

Returns active rules (with current decayed multiplier, fire count, and remaining TTL), queue states (buffer sizes, policies), config summary, and recently seen threads.


Alert message format #

Immediate alerts include the hex ref, thread indicator, and salience with rule attribution:

📨 [04a]🧵 @handle replied — 2026-05-28T10:30:00Z (5m ago)
salience: 0.78 [base 0.39 × rules ×2.0]

post text here

thread: at://did:.../app.bsky.feed.post/root
uri: at://did:.../app.bsky.feed.post/this

The [04a] ref can be used directly to reply (replyToMessageId: "04a"), like ({"op": "fav", "ref": "04a"}), or repost. The 🧵 indicates this post is part of a thread.


Outbound posting #

The agent posts to Bluesky via the MessageChannel tool. To reply to a post, pass a hex ref (e.g., "04a") or AT URI as replyToMessageId. Hex refs are resolved to AT URIs automatically; CIDs are resolved by the plugin.

When the agent posts a reply, the plugin automatically:

  1. Creates a boost_thread rule (×2.5, exponential decay, 2h TTL)
  2. Creates a transient tracking queue with idle emission (10m quiet period) and fetchThread: true

The agent can override this by immediately sending a suppress_thread or remove_rule command.


Direct messages (DMs) #

The plugin can also act as a Bluesky DM channel via the app.bsky.chat.* lexicon. DMs are handled completely separately from the notification/feed salience pipeline: they are never scored and never dropped. The only suppression is an explicit, permanent mute.

Requirements #

  1. A DM-enabled app password. A normal app password cannot access chat — every call fails with a token-scope error. Regenerate your app password with "Allow access to your direct messages" checked (bsky.app → Settings → App Passwords). On startup the plugin probes chat access once; if it fails it logs a clear warning and runs without DMs (notifications/feeds still work).
  2. A dm route (see installation step 4).

How inbound DMs work #

The plugin polls chat.bsky.convo.getLog every chat_poll_interval_ms (default 10s). Incoming messages are debounced per conversation: the first message in a turn starts a timer, every message that lands before it expires is collected, and the whole turn is delivered as one message once the conversation has been quiet for dm_idle_seconds (default 12s). This catches the common "hi" / "loved your post" / "how are you?" burst as a single delivery instead of three separate agent turns. A hard cap of dm_max_wait_seconds (default 90s) guarantees delivery even if someone never stops typing.

A delivered turn looks like:

💬 DM — @alice.bsky.social (3 new messages)

[04a] @alice (just now): hi
[04b] @alice (just now): loved your robots joke <3
[04c] @alice (just now): how are you doing?

convo: 3kx...convoid
reply: send to chatId "dm" with replyToMessageId "04c"

DMs are delivered to chatId: "dm" with chatType: "direct". Each message gets a hex ref just like posts do.

Replying and sending #

  • Reply to a DM: send via the MessageChannel tool to chatId: "dm" with the DM's hex ref as replyToMessageId. The ref resolves to the conversation automatically. (You can also address a conversation directly with chatId: "dm:<convoId>".)
  • Start a new DM (or send by handle): use the control channel — {"op": "dm", "handle": "alice.bsky.social", "text": "hello"}.

DM control ops #

Op Example Effect
dm / dm_send {"op":"dm","handle":"alice.bsky.social","text":"hi"} Send (creates the convo if needed). Target by handle, did, ref, or convoId.
dm_list {"op":"dm_list"} List conversations with unread counts and convo ids.
dm_read {"op":"dm_read","ref":"04c"} Mark a conversation read.
dm_leave {"op":"dm_leave","convoId":"..."} Leave a conversation.
mute_dm {"op":"mute_dm","ref":"04c"} Permanently drop a sender's DMs (local + server-side mute).
unmute_dm {"op":"unmute_dm","handle":"alice.bsky.social"} Reverse a mute.

Muted senders are stored in dynamic-rules.json (mutedDms) and persist across restarts.

DM config (rules.json) #

Field Default Description
dm_enabled "auto" true / false / "auto" (probe at startup, disable on scope error)
chat_poll_interval_ms 10000 How often to poll getLog for new DMs
dm_idle_seconds 12 Debounce window — emit a turn once the convo is quiet this long
dm_max_wait_seconds 90 Hard cap on how long a turn can buffer before forced delivery

Known gotchas #

routing.yaml is actually JSON #

Letta names the routing file routing.yaml but parses it with JSON.parse. If you manually edit it, write JSON.

Edit files in the right place #

All runtime files are read from PLUGIN_DIR — ~/.letta/channels/bluesky/ at runtime. If your agent's edit tool creates files at a different path, they won't be picked up. When in doubt: ls ~/.letta/channels/ and verify.

sendDirectReply is suppressed #

Letta's auto-reply sendDirectReply path is suppressed to avoid turning agent output into stray AT Protocol posts. This is unrelated to Bluesky DMs — direct messages are handled explicitly through the app.bsky.chat.* lexicon and the dm chatId (see Direct messages), not through sendDirectReply.

DMs need a DM-enabled app password #

A standard app password returns a token-scope error on every chat.bsky.* call. Regenerate it with "Allow access to your direct messages" enabled. See Direct messages.

Custom queue chatIds need routes #

If you create a queue with a custom chatId (e.g., queue:security), you need to add a Letta route for that chatId or the emitted messages won't reach the agent.

Requires Letta Code ≥ 0.24.x #

If letta channels route add --channel bluesky says "Unknown channel", upgrade: npm install -g letta@latest.