An API you can curl, or open in a browser, to receive Bluesky data as markdown!
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add terminal UA detection, curl homepage, and agent skill embed

- middleware.ts: detect curl/wget/HTTPie/etc and rewrite / to /cli
- app/cli/route.ts: clean plain-text Markdown homepage for terminal clients
- page.tsx: add "Try in terminal" section with curl examples (j4ck.xyz,
jcsalterego.bsky.social, etc.); replace agent tab UI with embedded
skill.md viewer + single Copy button linking to agentskills.io
- FollowPrompt: fix "j4ck" → "jack" in fallback label text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

jack 0e65b3bd bb834fec

+343 -167
+91
app/cli/route.ts
··· 1 + import { NextRequest } from 'next/server' 2 + 3 + export const runtime = 'edge' 4 + export const revalidate = 3600 5 + 6 + export function GET(req: NextRequest) { 7 + const host = req.headers.get('host') ?? 'bsky-md.vercel.app' 8 + const scheme = host.startsWith('localhost') ? 'http' : 'https' 9 + const base = `${scheme}://${host}` 10 + 11 + const body = `# bsky.md — Bluesky as Markdown 12 + 13 + Fetch any public Bluesky profile, post, feed, or search as clean Markdown. 14 + No auth. No API key. Just HTTP. 15 + 16 + ${base} 17 + 18 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 19 + 20 + ## Quick start 21 + 22 + curl ${base}/profile/j4ck.xyz 23 + curl ${base}/profile/jcsalterego.bsky.social 24 + curl ${base}/profile/jcsalterego.bsky.social/feed 25 + curl ${base}/profile/j4ck.xyz/followers 26 + curl "${base}/search?q=atproto" 27 + curl ${base}/trending 28 + 29 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 30 + 31 + ## Endpoints 32 + 33 + /profile/:handle Bio, stats, pinned post 34 + /profile/:handle/feed Recent posts (paginated) 35 + /profile/:handle/post/:rkey Single post with embeds 36 + /profile/:handle/post/:rkey/thread Full thread 37 + /profile/:handle/feed/:rkey Public custom feed 38 + /profile/:handle/likes Posts the user liked 39 + /profile/:handle/followers Follower list 40 + /profile/:handle/following Following list 41 + /search?q=:query Full-text post search 42 + /trending Trending topics right now 43 + /llms.txt Machine-readable API guide (for agents) 44 + /skill.md Agent skill file (Claude, Cursor, Windsurf…) 45 + 46 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 47 + 48 + ## More examples 49 + 50 + # A specific post 51 + curl ${base}/profile/j4ck.xyz/post/$(echo "find a rkey at bsky.app/profile/j4ck.xyz") 52 + 53 + # Thread 54 + curl ${base}/profile/jcsalterego.bsky.social/post/<rkey>/thread 55 + 56 + # Custom feed 57 + curl ${base}/profile/bsky.app/feed/whats-hot 58 + 59 + # Pagination 60 + curl "${base}/profile/jcsalterego.bsky.social/feed?limit=5" 61 + 62 + # Search 63 + curl "${base}/search?q=%23rust+lang" 64 + curl "${base}/search?q=open+source" 65 + 66 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 67 + 68 + ## Add to your coding agent 69 + 70 + # Claude Code (global /bsky slash command) 71 + curl -s ${base}/skill.md > ~/.claude/commands/bsky.md 72 + 73 + # Any project (CLAUDE.md / .cursorrules / .windsurfrules) 74 + curl -s ${base}/skill.md >> CLAUDE.md 75 + 76 + # GitHub Copilot 77 + mkdir -p .github && curl -s ${base}/skill.md >> .github/copilot-instructions.md 78 + 79 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 80 + 81 + Source: https://tangled.org/j4ck.xyz/bsky-md 82 + ` 83 + 84 + return new Response(body, { 85 + headers: { 86 + 'Content-Type': 'text/plain; charset=utf-8', 87 + 'Cache-Control': 'public, max-age=3600, s-maxage=3600', 88 + 'X-Robots-Tag': 'noindex', 89 + }, 90 + }) 91 + }
+2 -2
app/components/FollowPrompt.tsx
··· 48 48 49 49 if (!visible) return null 50 50 51 - const displayName = profile?.displayName || 'j4ck.xyz' 51 + const displayName = profile?.displayName || 'jack' 52 52 const handle = profile?.handle || 'j4ck.xyz' 53 53 54 54 return ( ··· 71 71 </div> 72 72 73 73 <p className={s.label}> 74 - Built by j4ck — follow to hear about updates and new tools. 74 + Built by jack — follow to hear about updates and new tools. 75 75 </p> 76 76 77 77 <a
+133 -78
app/page.module.css
··· 495 495 flex-shrink: 0; 496 496 } 497 497 498 + /* ── Terminal Section ─────────────────────────────── */ 499 + .terminalSection { 500 + max-width: 900px; 501 + width: 100%; 502 + margin: 48px auto 0; 503 + padding: 0 24px; 504 + } 505 + 506 + .terminalSubtitle { 507 + font-size: 0.85rem; 508 + color: var(--text-2); 509 + margin-bottom: 16px; 510 + line-height: 1.5; 511 + } 512 + 513 + .terminalBlock { 514 + border-radius: 12px; 515 + overflow: hidden; 516 + border: 1px solid var(--border); 517 + background: #0d1117; 518 + } 519 + 520 + @media (prefers-color-scheme: light) { 521 + .terminalBlock { 522 + background: #1a1f27; 523 + } 524 + } 525 + 526 + .terminalBar { 527 + display: flex; 528 + gap: 6px; 529 + align-items: center; 530 + padding: 10px 14px; 531 + background: rgba(255,255,255,0.05); 532 + border-bottom: 1px solid rgba(255,255,255,0.07); 533 + } 534 + 535 + .terminalDot { 536 + width: 10px; 537 + height: 10px; 538 + border-radius: 50%; 539 + background: rgba(255,255,255,0.15); 540 + } 541 + 542 + .terminalDot:nth-child(1) { background: #ff5f57; } 543 + .terminalDot:nth-child(2) { background: #febc2e; } 544 + .terminalDot:nth-child(3) { background: #28c840; } 545 + 546 + .terminalCode { 547 + margin: 0; 548 + padding: 20px 22px; 549 + font-family: var(--mono); 550 + font-size: 0.8rem; 551 + line-height: 1.7; 552 + color: #a3b5cc; 553 + overflow-x: auto; 554 + white-space: pre; 555 + } 556 + 557 + .terminalHint { 558 + font-size: 0.78rem; 559 + color: var(--text-3); 560 + margin-top: 10px; 561 + line-height: 1.5; 562 + } 563 + 498 564 /* ── Endpoints Grid ───────────────────────────────── */ 499 565 .endpointsSection { 500 566 max-width: 900px; ··· 584 650 background: var(--bg); 585 651 } 586 652 587 - .agentTabs { 653 + .agentHeader { 588 654 display: flex; 589 - overflow-x: auto; 590 - border-bottom: 1px solid var(--border); 591 - background: var(--bg-2); 592 - scrollbar-width: none; 593 - -ms-overflow-style: none; 655 + align-items: flex-start; 656 + justify-content: space-between; 657 + gap: 16px; 658 + padding: 20px 20px 16px; 659 + flex-wrap: wrap; 594 660 } 595 - .agentTabs::-webkit-scrollbar { display: none; } 596 661 597 - .agentTab { 598 - display: flex; 662 + .agentDesc { 663 + font-size: 0.85rem; 664 + color: var(--text-2); 665 + margin-bottom: 8px; 666 + line-height: 1.5; 667 + max-width: 540px; 668 + } 669 + 670 + .agentSkillsLink { 671 + font-size: 0.78rem; 672 + color: var(--blue); 673 + text-decoration: none; 674 + } 675 + 676 + .agentSkillsLink:hover { 677 + text-decoration: underline; 678 + } 679 + 680 + .skillCopyBtn { 681 + display: inline-flex; 599 682 align-items: center; 600 683 gap: 6px; 601 - padding: 11px 16px; 602 - font-size: 0.82rem; 603 - font-weight: 500; 604 - color: var(--text-2); 605 - background: transparent; 684 + padding: 9px 18px; 685 + font-size: 0.85rem; 686 + font-weight: 600; 687 + background: var(--blue); 688 + color: #fff; 606 689 border: none; 607 - border-bottom: 2px solid transparent; 690 + border-radius: 8px; 608 691 cursor: pointer; 609 692 white-space: nowrap; 610 - transition: color 0.12s, border-color 0.12s, background 0.12s; 611 693 flex-shrink: 0; 694 + transition: background 0.15s, transform 0.1s; 612 695 } 613 696 614 - .agentTab:hover { 615 - color: var(--text); 616 - background: var(--bg); 697 + .skillCopyBtn:hover:not(:disabled) { 698 + background: #006ad4; 617 699 } 618 700 619 - .agentTabActive { 620 - color: var(--blue); 621 - border-bottom-color: var(--blue); 622 - background: var(--bg); 701 + .skillCopyBtn:active:not(:disabled) { 702 + transform: scale(0.97); 623 703 } 624 704 625 - .agentBody { 626 - padding: 20px 20px 16px; 705 + .skillCopyBtn:disabled { 706 + opacity: 0.5; 707 + cursor: default; 627 708 } 628 709 629 - .agentDesc { 630 - font-size: 0.85rem; 631 - color: var(--text-2); 632 - margin-bottom: 12px; 633 - line-height: 1.5; 710 + .skillCopyBtnDone { 711 + background: #16a34a !important; 634 712 } 635 713 636 - .codeBlock { 637 - display: flex; 638 - align-items: stretch; 639 - border: 1px solid var(--border); 640 - border-radius: 8px; 641 - overflow: hidden; 714 + .skillEmbed { 715 + margin: 0; 716 + padding: 18px 20px; 717 + font-family: var(--mono); 718 + font-size: 0.76rem; 719 + line-height: 1.65; 720 + color: var(--text-2); 642 721 background: var(--bg-2); 722 + border-top: 1px solid var(--border); 723 + border-bottom: 1px solid var(--border); 724 + overflow: auto; 725 + max-height: 320px; 726 + white-space: pre; 643 727 } 644 728 645 - .codeText { 646 - flex: 1; 647 - padding: 12px 14px; 648 - font-family: var(--mono); 649 - font-size: 0.8rem; 650 - color: var(--text); 651 - overflow-x: auto; 652 - white-space: nowrap; 653 - scrollbar-width: thin; 654 - line-height: 1.4; 729 + .agentFooter { 730 + display: flex; 731 + align-items: center; 732 + gap: 10px; 733 + padding: 12px 20px; 734 + flex-wrap: wrap; 655 735 } 656 736 657 - .codeCopy { 658 - padding: 0 16px; 737 + .agentRawLink { 659 738 font-size: 0.78rem; 660 - font-weight: 500; 661 - background: var(--bg); 662 - border: none; 663 - border-left: 1px solid var(--border); 664 - cursor: pointer; 665 - color: var(--text-2); 666 - white-space: nowrap; 667 - transition: background 0.1s, color 0.1s; 668 - flex-shrink: 0; 669 - } 670 - 671 - .codeCopy:hover { 672 - background: var(--blue-pale); 673 739 color: var(--blue); 740 + text-decoration: none; 741 + white-space: nowrap; 674 742 } 675 743 676 - .codeCopyDone { 677 - color: #16a34a !important; 678 - background: #dcfce7 !important; 679 - } 680 - 681 - @media (prefers-color-scheme: dark) { 682 - .codeCopyDone { 683 - color: #4ade80 !important; 684 - background: #052e16 !important; 685 - } 744 + .agentRawLink:hover { 745 + text-decoration: underline; 686 746 } 687 747 688 - .agentNote { 689 - margin-top: 10px; 690 - font-size: 0.78rem; 748 + .agentFooterHint { 749 + font-size: 0.75rem; 691 750 color: var(--text-3); 692 751 line-height: 1.5; 693 - } 694 - 695 - .agentNote a { 696 - color: var(--blue); 697 752 } 698 753 699 754 /* ── Footer ───────────────────────────────────────── */
+82 -87
app/page.tsx
··· 1 1 'use client' 2 2 3 - import { useState, useCallback, useRef } from 'react' 3 + import { useState, useCallback, useRef, useEffect } from 'react' 4 4 import s from './page.module.css' 5 5 6 6 // ── URL parser ──────────────────────────────────────────────────────────────── ··· 67 67 return `${(n / 1000).toFixed(1)}k chars` 68 68 } 69 69 70 - // ── Agent install data ──────────────────────────────────────────────────────── 71 - 72 - const AGENTS = [ 73 - { 74 - id: 'claude-code', 75 - label: 'Claude Code', 76 - icon: '🤖', 77 - command: 'curl -s https://bsky-md.vercel.app/skill.md > ~/.claude/commands/bsky.md', 78 - desc: 'Installs a global /bsky slash command. Use it in any Claude Code session with /bsky.', 79 - note: 'After running, type <code>/bsky</code> in any Claude Code conversation to activate.', 80 - }, 81 - { 82 - id: 'claude-md', 83 - label: 'CLAUDE.md', 84 - icon: '📄', 85 - command: 'curl -s https://bsky-md.vercel.app/skill.md >> CLAUDE.md', 86 - desc: 'Appends the full API reference to your project\'s CLAUDE.md — Claude will use it automatically for every conversation in this project.', 87 - note: 'Run this in your project root. Works for Claude Code, Cursor, and any agent that reads CLAUDE.md.', 88 - }, 89 - { 90 - id: 'cursor', 91 - label: 'Cursor', 92 - icon: '⌨️', 93 - command: 'curl -s https://bsky-md.vercel.app/skill.md >> .cursorrules', 94 - desc: 'Appends the API reference to your Cursor project rules.', 95 - note: 'Cursor reads .cursorrules automatically for every chat in this workspace.', 96 - }, 97 - { 98 - id: 'windsurf', 99 - label: 'Windsurf', 100 - icon: '🏄', 101 - command: 'curl -s https://bsky-md.vercel.app/skill.md >> .windsurfrules', 102 - desc: 'Appends the API reference to your Windsurf workspace rules.', 103 - note: 'Windsurf reads .windsurfrules for every Cascade conversation in this workspace.', 104 - }, 105 - { 106 - id: 'copilot', 107 - label: 'Copilot', 108 - icon: '🐙', 109 - command: 'mkdir -p .github && curl -s https://bsky-md.vercel.app/skill.md >> .github/copilot-instructions.md', 110 - desc: 'Appends the API reference to your GitHub Copilot workspace instructions.', 111 - note: 'GitHub Copilot reads .github/copilot-instructions.md automatically.', 112 - }, 113 - { 114 - id: 'raw', 115 - label: 'Raw file', 116 - icon: '📋', 117 - command: 'curl -s https://bsky-md.vercel.app/skill.md', 118 - desc: 'Print the raw skill file — paste it into any agent context, system prompt, or rules file.', 119 - note: 'Or just open <a href="/skill.md" target="_blank" rel="noopener noreferrer">bsky-md.vercel.app/skill.md</a> in your browser.', 120 - }, 121 - ] 122 70 123 71 // ── Catalogue ───────────────────────────────────────────────────────────────── 124 72 ··· 155 103 const [error, setError] = useState<string | null>(null) 156 104 const [copiedUrl, setCopiedUrl] = useState(false) 157 105 const [copiedMd, setCopiedMd] = useState(false) 158 - const [activeAgent, setActiveAgent] = useState('claude-code') 159 - const [copiedCmd, setCopiedCmd] = useState(false) 106 + const [skillMd, setSkillMd] = useState<string | null>(null) 107 + const [copiedSkill, setCopiedSkill] = useState(false) 160 108 const abortRef = useRef<AbortController | null>(null) 161 109 const resultRef = useRef<HTMLDivElement | null>(null) 162 110 ··· 235 183 }) 236 184 }, [markdown]) 237 185 238 - const copyCmd = useCallback((cmd: string) => { 239 - navigator.clipboard.writeText(cmd).then(() => { 240 - setCopiedCmd(true) 241 - setTimeout(() => setCopiedCmd(false), 2000) 186 + const copySkill = useCallback(() => { 187 + if (!skillMd) return 188 + navigator.clipboard.writeText(skillMd).then(() => { 189 + setCopiedSkill(true) 190 + setTimeout(() => setCopiedSkill(false), 2000) 242 191 }) 192 + }, [skillMd]) 193 + 194 + useEffect(() => { 195 + fetch('/skill.md').then(r => r.text()).then(setSkillMd).catch(() => {}) 243 196 }, []) 244 197 245 198 const activePath = parsed ? getPath(parsed, parsed.isPost ? viewMode : 'post') : null 246 - const currentAgent = AGENTS.find((a) => a.id === activeAgent) ?? AGENTS[0] 247 199 const charCount = markdown ? markdown.length : 0 248 200 249 201 return ( ··· 382 334 <div className={s.infoItem}><span className={s.infoIcon}>🤖</span> LLM-friendly plain text</div> 383 335 </div> 384 336 337 + {/* ── Terminal examples ── */} 338 + <section className={s.terminalSection}> 339 + <p className={s.sectionTitle}>Works great in your terminal too</p> 340 + <p className={s.terminalSubtitle}> 341 + <code>curl</code> any endpoint and get plain Markdown back — pipe it to <code>glow</code>, your agent, or just read it. 342 + </p> 343 + <div className={s.terminalBlock}> 344 + <div className={s.terminalBar}> 345 + <span className={s.terminalDot} /> 346 + <span className={s.terminalDot} /> 347 + <span className={s.terminalDot} /> 348 + </div> 349 + <pre className={s.terminalCode}>{[ 350 + '# Profile', 351 + 'curl https://bsky-md.vercel.app/profile/j4ck.xyz', 352 + '', 353 + '# Recent posts', 354 + 'curl https://bsky-md.vercel.app/profile/jcsalterego.bsky.social/feed', 355 + '', 356 + '# Followers', 357 + 'curl https://bsky-md.vercel.app/profile/j4ck.xyz/followers', 358 + '', 359 + '# Search', 360 + 'curl "https://bsky-md.vercel.app/search?q=atproto"', 361 + '', 362 + '# Trending topics', 363 + 'curl https://bsky-md.vercel.app/trending', 364 + '', 365 + '# Custom feed', 366 + 'curl https://bsky-md.vercel.app/profile/bsky.app/feed/whats-hot', 367 + '', 368 + '# Agent skill file', 369 + 'curl -s https://bsky-md.vercel.app/skill.md > ~/.claude/commands/bsky.md', 370 + ].join('\n')}</pre> 371 + </div> 372 + <p className={s.terminalHint}> 373 + Tip: visiting this URL from <code>curl</code> or any terminal client automatically returns Markdown — no flags needed. 374 + </p> 375 + </section> 376 + 385 377 {/* ── Endpoints ── */} 386 378 <section className={s.endpointsSection} style={{ marginTop: 48 }}> 387 379 <p className={s.sectionTitle}>All Endpoints</p> ··· 400 392 <section className={s.agentSection}> 401 393 <p className={s.sectionTitle}>Add to your coding agent</p> 402 394 <div className={s.agentCard}> 403 - {/* Tab row */} 404 - <div className={s.agentTabs}> 405 - {AGENTS.map((a) => ( 406 - <button 407 - key={a.id} 408 - className={`${s.agentTab} ${activeAgent === a.id ? s.agentTabActive : ''}`} 409 - onClick={() => { setActiveAgent(a.id); setCopiedCmd(false) }} 410 - > 411 - {a.icon} {a.label} 412 - </button> 413 - ))} 414 - </div> 415 - 416 - {/* Body */} 417 - <div className={s.agentBody}> 418 - <p className={s.agentDesc}>{currentAgent.desc}</p> 419 - <div className={s.codeBlock}> 420 - <code className={s.codeText}>$ {currentAgent.command}</code> 421 - <button 422 - className={`${s.codeCopy} ${copiedCmd ? s.codeCopyDone : ''}`} 423 - onClick={() => copyCmd(currentAgent.command)} 395 + <div className={s.agentHeader}> 396 + <div> 397 + <p className={s.agentDesc}> 398 + Copy this skill file and paste it into any coding agent — Claude, Cursor, Windsurf, Copilot, or any tool that accepts a system prompt or rules file. 399 + </p> 400 + <a 401 + className={s.agentSkillsLink} 402 + href="https://agentskills.io/home" 403 + target="_blank" 404 + rel="noopener noreferrer" 424 405 > 425 - {copiedCmd ? '✓ Copied' : 'Copy'} 426 - </button> 406 + Learn about agent skills at agentskills.io ↗ 407 + </a> 427 408 </div> 428 - <p 429 - className={s.agentNote} 430 - dangerouslySetInnerHTML={{ __html: currentAgent.note }} 431 - /> 409 + <button 410 + className={`${s.skillCopyBtn} ${copiedSkill ? s.skillCopyBtnDone : ''}`} 411 + onClick={copySkill} 412 + disabled={!skillMd} 413 + > 414 + {copiedSkill ? '✓ Copied!' : '📋 Copy skill.md'} 415 + </button> 416 + </div> 417 + <pre className={s.skillEmbed}> 418 + {skillMd ?? 'Loading…'} 419 + </pre> 420 + <div className={s.agentFooter}> 421 + <a href="/skill.md" target="_blank" rel="noopener noreferrer" className={s.agentRawLink}> 422 + View raw ↗ 423 + </a> 424 + <span className={s.agentFooterHint}> 425 + or <code>curl -s https://bsky-md.vercel.app/skill.md {'>'} ~/.claude/commands/bsky.md</code> for Claude Code 426 + </span> 432 427 </div> 433 428 </div> 434 429 </section>
+35
middleware.ts
··· 1 + import { NextRequest, NextResponse } from 'next/server' 2 + 3 + function isTerminalClient(req: NextRequest): boolean { 4 + const ua = req.headers.get('user-agent') ?? '' 5 + const accept = req.headers.get('accept') ?? '' 6 + 7 + // Known terminal / CLI clients 8 + if (/^(curl|Wget|HTTPie|httpie|xh\/|python-httpx|python-requests|Go-http-client|nushell|Nu\/|httpx\/)/i.test(ua)) { 9 + return true 10 + } 11 + 12 + // Anything that explicitly accepts text/markdown or text/plain first 13 + if (/^text\/(markdown|plain)/.test(accept)) return true 14 + 15 + // No Mozilla = definitely not a browser 16 + // (every real browser sends "Mozilla/5.0 ...") 17 + if (ua && !ua.includes('Mozilla') && !accept.includes('text/html')) { 18 + return true 19 + } 20 + 21 + return false 22 + } 23 + 24 + export function middleware(req: NextRequest) { 25 + if (req.nextUrl.pathname === '/' && isTerminalClient(req)) { 26 + const url = req.nextUrl.clone() 27 + url.pathname = '/cli' 28 + return NextResponse.rewrite(url) 29 + } 30 + return NextResponse.next() 31 + } 32 + 33 + export const config = { 34 + matcher: '/', 35 + }