bluesky

v1.0.0

Post to Bluesky (AT Protocol). Use when the user asks to post a tweet/thread to Bluesky, share content on Bluesky, or post news from a URL.

How to install

Point your Sulala Agent at this store, then install this skill.

  1. Set the registry URL (e.g. in .env):
SKILLS_REGISTRY_URL=https://hub.sulala.ai/api/sulalahub/registry

Then run: sulala skill install bluesky or install from the dashboard Skills page.

Skill doc

---
name: bluesky
description: Post to Bluesky (AT Protocol). Use when the user asks to post a tweet/thread to Bluesky, share content on Bluesky, or post news/headlines from a source (URL or text) to Bluesky.
homepage: https://bsky.app
metadata:
  {
    "sulala": {
      "emoji": "🦋",
      "requires": { "bins": ["curl", "python3", "sh"], "env": ["BSKY_HANDLE", "BSKY_APP_PASSWORD"] },
      "primaryEnv": "BSKY_APP_PASSWORD"
    }
  }
---

# Bluesky Posting

Post to Bluesky via the AT Protocol. Use **run_command** with `curl`, `python3`, and `sh`. Add `curl`, `python3`, and `sh` to ALLOWED_BINARIES.

Requires `BSKY_HANDLE` (e.g. `your.bsky.social`) and `BSKY_APP_PASSWORD` (app password from bsky.app → Settings → App Passwords). Set them in `.env` or in the skill config (`~/.sulala/config.json` or `.sulala/config.json` in the project; see Skill config in the dashboard). Config is re-read when the file changes; enable/disable takes effect without restart.

**IMPORTANT:** `run_command` does not run a shell — `$BSKY_HANDLE` and `$BSKY_APP_PASSWORD` will not expand if you call curl directly. Always use `binary: "sh"` and `args: ["-c", "curl ... $BSKY_HANDLE ... $BSKY_APP_PASSWORD ..."]` so the env vars expand.

## When to Use

- "Post this to Bluesky"
- "Share [content] on Bluesky"
- "Post news from [URL] to Bluesky"
- "Post a headline about [topic]"

## Post Flow (non-interactive)

### 1. Ensure credentials

```
BSKY_HANDLE="${BSKY_HANDLE:?Set BSKY_HANDLE}"
BSKY_APP_PASSWORD="${BSKY_APP_PASSWORD:?Set BSKY_APP_PASSWORD}"
```

If empty, read from Sulala config:

```
CONFIG_PATH="${SULALA_CONFIG_PATH:-$HOME/.sulala/config.json}"
# If using workspace config, use: CONFIG_PATH=".sulala/config.json" (when run from project root)
BSKY_HANDLE=$(cat "$CONFIG_PATH" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('skills',{}).get('entries',{}).get('bluesky',{}); print(e.get('handle','') or e.get('apiKey',''))" 2>/dev/null)
BSKY_APP_PASSWORD=$(cat "$CONFIG_PATH" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('skills',{}).get('entries',{}).get('bluesky',{}); print(e.get('apiKey','') or e.get('password',''))" 2>/dev/null)
```

(Config may use `handle` + `apiKey` for Bluesky. Adjust field names if your config differs.)

### 2. Create session (login)

**Use `run_command` with `binary: "sh"` and `args: ["-c", "..."]`** so `$BSKY_HANDLE` and `$BSKY_APP_PASSWORD` expand from the environment. Do NOT call curl directly.

```bash
sh -c 'SESSION=$(curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" -H "Content-Type: application/json" -d "{\"identifier\": \"$BSKY_HANDLE\", \"password\": \"$BSKY_APP_PASSWORD\"}"); echo "$SESSION"'
```

Then parse the JSON output with `python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('accessJwt',''), d.get('did',''))"` to get `ACCESS_TOKEN` and `DID`.

If the session response contains `"error"` or `"AuthenticationRequired"`, report: "Bluesky login failed. Check BSKY_HANDLE and BSKY_APP_PASSWORD in .env."

### 3. Create post

Text must be JSON-escaped. Use python to build the payload. **Use `run_command` with `binary: "sh"`** so `$ACCESS_TOKEN`, `$DID`, and other vars expand.

Run a single `sh -c` that chains steps 2 and 3 so SESSION, ACCESS_TOKEN, DID persist in the same shell:

```bash
sh -c '
  SESSION=$(curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
    -H "Content-Type: application/json" \
    -d "{\"identifier\": \"$BSKY_HANDLE\", \"password\": \"$BSKY_APP_PASSWORD\"}");
  ACCESS_TOKEN=$(echo "$SESSION" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"accessJwt\",\"\"))");
  DID=$(echo "$SESSION" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"did\",\"\"))");
  if [ -z "$ACCESS_TOKEN" ]; then echo "Login failed"; exit 1; fi;
  POST_TEXT="Your post content here";
  POST_JSON=$(python3 -c "import sys,json; print(json.dumps(sys.argv[1]))" "$POST_TEXT");
  NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z");
  curl -s -X POST "https://bsky.social/xrpc/com.atproto.repo.createRecord" \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"repo\": \"$DID\", \"collection\": \"app.bsky.feed.post\", \"record\": {\"\\$type\": \"app.bsky.feed.post\", \"text\": $POST_JSON, \"createdAt\": \"$NOW\"}}"
'
```

Replace `Your post content here` with the user's post text.

If the response contains `"uri"`, the post succeeded. Otherwise report the error.

## Posting from a news source

When the user wants to post **news** or **content from a URL**:

1. **Fetch the content** (e.g. with `curl -s <URL>` or read a file).
2. **Summarize** if the content is long — Bluesky posts are max 300 characters. Write a short headline or summary.
3. **Post** using the flow above. Optionally append the source URL if it fits within 300 chars.

Example for "post news from https://example.com/article":
- Fetch the page, extract title/lead.
- Build post: "Headline here — https://example.com/article"
- Post via the createRecord flow above.

## Character limit

Bluesky posts are limited to 300 characters. If the user's text or your summary exceeds that, truncate or split into multiple posts (thread). For a thread, create each post separately; the API does not natively support threads — you would need to reference the previous post's URI if linking them (advanced).

## Notes

- Use an **app password**, not the main account password. Create at bsky.app → Settings → App Passwords.
- `bsky.social` is the default PDS; custom PDS users would need a different host.
- Do not embed real credentials in the skill or in replies — use env or config.