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 theidinchannel.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 viaaccounts.jsonandroute 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
dmroute is required for direct messages. All Bluesky DMs flow through the singledmchatId (the sender is identified inside the message). See Direct messages — and note that DMs also need a DM-enabled app password.
The
controlroute 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 from001throughfff(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:
- Creates a
boost_threadrule (×2.5, exponential decay, 2h TTL) - Creates a transient tracking queue with
idleemission (10m quiet period) andfetchThread: 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 #
- 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).
- A
dmroute (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 asreplyToMessageId. The ref resolves to the conversation automatically. (You can also address a conversation directly withchatId: "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.