From 813a30ce1f8f9d50b8c2fad76b5dd4c69427f5a5 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 14:01:22 -0500 Subject: [PATCH] =?UTF-8?q?F.2:=20WriteFreely=20bundle=20=E2=80=94=20feder?= =?UTF-8?q?ated=20long-form=20blog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second federated app, stacked on F.1 (GoToSocial). WriteFreely is the minimalist publish-oriented ActivityPub server — single-binary, single-SQLite-file, no comment system, no likes, no moderation queue. Ideal for long-form writing that federates to Mastodon / GoToSocial followers. Complements GoToSocial (microblog) and Crow's own private crow-blog (non-federated). Bundle (bundles/writefreely/): - manifest.json consent_required: true with EN/ES blast-radius text (narrower than GTS because WF doesn't moderate remote content). min_ram_mb=256, recommended=512. Lightest of the federated bundles; Pi-eligible on any tier - docker-compose.yml pinned writeas/writefreely:0.15, joins crow- federation, no host port publish, SQLite default. Entrypoint seeds config.ini on first boot + runs --init-db / --gen-keys once so the container boots clean from a fresh volume - server/server.js 10 MCP tools: wf_status, wf_list_collections, wf_create_post, wf_update_post, wf_publish_post, wf_unpublish_post, wf_delete_post, wf_list_posts, wf_get_post, wf_export_posts Content + publish verbs wrapped with the shared rate limiter (same try/catch fallback for installed-mode as the GTS bundle). No moderation queue — WF doesn't moderate remote content; that's intentional per the upstream design and the plan's "not every bundle needs the full moderation taxonomy" note - skills/writefreely.md triggers, draft→publish workflow, single- user mode shortcut, Caddy expose recipe, explicit "what WriteFreely doesn't do" section to avoid tool confusion (no comments, no likes, no moderation) - panel/ status (instance, auth, collections chip list) + recent posts from the default collection. XSS-safe (text Content / createElement only) - scripts/ backup.sh (online sqlite-backup + keys+config tar with warning that actor keys don't transfer across domains), post-install.sh (next-step output) Platform wiring: - registry/add-ons.json writefreely entry, federated-social category - skills/superpowers.md EN/ES trigger row - CLAUDE.md Skills Reference entry Verified: - node --check on all changed files - MCP server boots via createWritefreelyServer() - docker compose config parses - registry JSON validates - Branch stacked correctly on f1-gotosocial-bundle --- CLAUDE.md | 1 + bundles/writefreely/.env.example | 30 ++ bundles/writefreely/docker-compose.yml | 106 +++++ bundles/writefreely/manifest.json | 66 +++ bundles/writefreely/package.json | 11 + bundles/writefreely/panel/routes.js | 71 ++++ bundles/writefreely/panel/writefreely.js | 149 +++++++ bundles/writefreely/scripts/backup.sh | 34 ++ bundles/writefreely/scripts/post-install.sh | 38 ++ bundles/writefreely/server/index.js | 12 + bundles/writefreely/server/server.js | 424 ++++++++++++++++++++ bundles/writefreely/skills/writefreely.md | 105 +++++ registry/add-ons.json | 38 ++ skills/superpowers.md | 1 + 14 files changed, 1086 insertions(+) create mode 100644 bundles/writefreely/.env.example create mode 100644 bundles/writefreely/docker-compose.yml create mode 100644 bundles/writefreely/manifest.json create mode 100644 bundles/writefreely/package.json create mode 100644 bundles/writefreely/panel/routes.js create mode 100644 bundles/writefreely/panel/writefreely.js create mode 100755 bundles/writefreely/scripts/backup.sh create mode 100755 bundles/writefreely/scripts/post-install.sh create mode 100644 bundles/writefreely/server/index.js create mode 100644 bundles/writefreely/server/server.js create mode 100644 bundles/writefreely/skills/writefreely.md diff --git a/CLAUDE.md b/CLAUDE.md index 4ec1a23..f9e21d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -463,6 +463,7 @@ Add-on skills (activated when corresponding add-on is installed): - `knowledge-base.md` — Multilingual knowledge base: create, edit, publish, search, verify resources, share articles, LAN discovery - `maker-lab.md` — STEM education companion for kids: scaffolded AI tutor, hint-ladder pedagogy, age-banded personas (kid/tween/adult), solo/family/classroom modes, guest sidecar - `gotosocial.md` — GoToSocial ActivityPub microblog: post, follow, search, moderate (block_user/mute inline; defederate/block_domain/import_blocklist queued for operator confirmation), media prune, federation health +- `writefreely.md` — WriteFreely federated blog: create/update/publish/unpublish posts, list collections, fetch public posts, export; minimalist publisher (no comments, no moderation queue — WF is publish-oriented only) - `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS - `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download - `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read diff --git a/bundles/writefreely/.env.example b/bundles/writefreely/.env.example new file mode 100644 index 0000000..1f9461f --- /dev/null +++ b/bundles/writefreely/.env.example @@ -0,0 +1,30 @@ +# WriteFreely — required config + +# Public domain dedicated to WriteFreely (subdomain; ActivityPub actors +# are URL-keyed so subpath mounts break federation). +WF_HOST=blog.example.com + +# Internal URL the Crow MCP server uses. Leave as-is when running over +# the shared crow-federation docker network. +WF_URL=http://writefreely:8080 + +# API access token for the admin account. After first-run web setup, +# obtain via: +# curl -X POST https:///api/auth/login \ +# -H 'Content-Type: application/json' \ +# -d '{"alias":"","pass":""}' +# Copy the returned access_token here. Without a token the MCP tools +# work in read-only mode against public posts. +WF_ACCESS_TOKEN= + +# Default collection (blog) alias. Single-user mode creates an implicit +# collection you can leave blank here. Multi-user installs require an +# explicit alias per post; set this to your primary collection. +WF_COLLECTION_ALIAS= + +# Single-user mode: simpler setup, one implicit collection, the admin +# IS the blog. Multi-user (false): Medium-like multi-author platform. +WF_SINGLE_USER=true + +# Host data directory override. +# WF_DATA_DIR=/mnt/ssd/writefreely diff --git a/bundles/writefreely/docker-compose.yml b/bundles/writefreely/docker-compose.yml new file mode 100644 index 0000000..66a9f15 --- /dev/null +++ b/bundles/writefreely/docker-compose.yml @@ -0,0 +1,106 @@ +# WriteFreely — ActivityPub blogging platform. +# +# Deployment: no host port publish. Caddy reaches writefreely:8080 by +# docker service name over the crow-federation network. +# +# Data at ~/.crow/writefreely/ — SQLite + uploads + keys. Migration to +# MySQL requires manual compose edit. + +networks: + crow-federation: + external: true + default: + +services: + writefreely: + image: writeas/writefreely:0.15 + container_name: crow-writefreely + networks: + - default + - crow-federation + environment: + # WriteFreely reads its config from /go/keys/config.ini. The + # entrypoint below seeds that file on first run using the env + # vars below, then execs into writefreely. + WF_HOST: ${WF_HOST} + WF_SINGLE_USER: ${WF_SINGLE_USER:-true} + WF_DB_TYPE: sqlite3 + WF_DB_FILENAME: /go/keys/writefreely.db + WF_PORT: "8080" + WF_BIND_ADDRESS: 0.0.0.0 + # Behind Caddy: let WriteFreely know the public scheme is HTTPS so + # generated absolute URLs (ActivityPub actors, webfinger) use it. + WF_PUBLIC_SCHEME: https + volumes: + - ${WF_DATA_DIR:-~/.crow/writefreely}:/go/keys + entrypoint: + - sh + - -c + - | + set -e + CFG=/go/keys/config.ini + if [ ! -f "$$CFG" ]; then + cat > "$$CFG" </dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s diff --git a/bundles/writefreely/manifest.json b/bundles/writefreely/manifest.json new file mode 100644 index 0000000..707d739 --- /dev/null +++ b/bundles/writefreely/manifest.json @@ -0,0 +1,66 @@ +{ + "id": "writefreely", + "name": "WriteFreely", + "version": "1.0.0", + "description": "Lightweight, minimalist ActivityPub blogging platform. Single-binary, single-SQLite-file footprint — ideal for long-form writing federated to the fediverse. Complements GoToSocial (microblog) and Crow's own blog.", + "type": "bundle", + "author": "Crow", + "category": "federated-social", + "tags": ["activitypub", "fediverse", "blog", "long-form", "federated", "writing"], + "icon": "file-text", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["WF_URL", "WF_ACCESS_TOKEN", "WF_COLLECTION_ALIAS"] + }, + "panel": "panel/writefreely.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/writefreely.md"], + "consent_required": true, + "install_consent_messages": { + "en": "WriteFreely joins the fediverse: your blog becomes publicly addressable at the domain you configure, and posts marked public are federated via ActivityPub to following Mastodon / GoToSocial / other instances. Replicated posts cannot be fully recalled — deletions may not reach every server that boosted your content. WriteFreely itself does not moderate remote content (it is publish-only); inbound federation is limited to follow-from-remote events, so moderation surface is narrow. If your instance is reported for abuse, the normal defederation risks apply — a poisoned domain cannot easily be rehabilitated.", + "es": "WriteFreely se une al fediverso: tu blog será públicamente direccionable en el dominio que configures, y las publicaciones marcadas como públicas se federan vía ActivityPub a instancias que te sigan (Mastodon, GoToSocial, etc.). Las publicaciones replicadas no pueden recuperarse completamente — las eliminaciones pueden no llegar a todos los servidores que hicieron boost de tu contenido. WriteFreely en sí mismo no modera contenido remoto (es solo de publicación); la federación entrante se limita a eventos de seguimiento, por lo que la superficie de moderación es estrecha. Si tu instancia es reportada por abuso, aplican los riesgos normales de defederación — un dominio envenenado no puede rehabilitarse fácilmente." + }, + "requires": { + "env": ["WF_HOST"], + "bundles": ["caddy"], + "min_ram_mb": 256, + "recommended_ram_mb": 512, + "min_disk_mb": 500, + "recommended_disk_mb": 5000 + }, + "env_vars": [ + { + "name": "WF_HOST", + "description": "Public domain for this WriteFreely instance (must be a subdomain; subpath mounts break ActivityPub).", + "required": true + }, + { + "name": "WF_URL", + "description": "Internal URL the Crow MCP server uses to reach WriteFreely (over the crow-federation docker network).", + "default": "http://writefreely:8080", + "required": false + }, + { + "name": "WF_ACCESS_TOKEN", + "description": "API access token for the admin account. Obtain via POST /api/auth/login after first-run web setup (or from the WriteFreely CLI).", + "required": false, + "secret": true + }, + { + "name": "WF_COLLECTION_ALIAS", + "description": "Default collection (blog) alias for post operations. Single-user blogs typically have one collection; multi-user installs require explicit alias per post.", + "required": false + }, + { + "name": "WF_SINGLE_USER", + "description": "Set to true for single-user blog mode (simpler setup, one implicit collection). false for multi-user Medium-like mode.", + "default": "true", + "required": false + } + ], + "ports": [], + "webUI": null, + "notes": "No host port publish. Expose via Caddy after install: caddy_add_federation_site { domain: WF_HOST, upstream: 'writefreely:8080', profile: 'activitypub' }. On first-run the admin account is created via the web UI; there is no CLI bootstrap." +} diff --git a/bundles/writefreely/package.json b/bundles/writefreely/package.json new file mode 100644 index 0000000..5e9ace6 --- /dev/null +++ b/bundles/writefreely/package.json @@ -0,0 +1,11 @@ +{ + "name": "crow-writefreely", + "version": "1.0.0", + "description": "WriteFreely MCP server — create, publish, and federate long-form blog posts over ActivityPub", + "type": "module", + "main": "server/index.js", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + } +} diff --git a/bundles/writefreely/panel/routes.js b/bundles/writefreely/panel/routes.js new file mode 100644 index 0000000..76b97e9 --- /dev/null +++ b/bundles/writefreely/panel/routes.js @@ -0,0 +1,71 @@ +/** + * WriteFreely panel API routes — read-only status + recent posts. + */ + +import { Router } from "express"; + +const WF_URL = () => (process.env.WF_URL || "http://writefreely:8080").replace(/\/+$/, ""); +const WF_TOKEN = () => process.env.WF_ACCESS_TOKEN || ""; +const WF_COLL = () => process.env.WF_COLLECTION_ALIAS || ""; +const TIMEOUT = 10_000; + +async function wf(path, { noAuth } = {}) { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), TIMEOUT); + try { + const headers = { Accept: "application/json" }; + if (!noAuth && WF_TOKEN()) headers.Authorization = `Token ${WF_TOKEN()}`; + const r = await fetch(`${WF_URL()}${path}`, { signal: ctl.signal, headers }); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); + const text = await r.text(); + if (!text) return {}; + const parsed = JSON.parse(text); + return parsed?.data !== undefined && parsed?.code ? parsed.data : parsed; + } finally { + clearTimeout(t); + } +} + +export default function writefreelyRouter(authMiddleware) { + const router = Router(); + + router.get("/api/writefreely/status", authMiddleware, async (_req, res) => { + try { + const me = WF_TOKEN() ? await wf("/api/me").catch(() => null) : null; + const colls = WF_TOKEN() ? await wf("/api/me/collections").catch(() => []) : []; + res.json({ + instance_url: WF_URL(), + has_token: Boolean(WF_TOKEN()), + authenticated_as: me?.username || null, + collections: Array.isArray(colls) ? colls.map((c) => ({ alias: c.alias, title: c.title, posts: c.total_posts })) : [], + default_collection: WF_COLL() || null, + }); + } catch (err) { + res.json({ error: `Cannot reach WriteFreely: ${err.message}` }); + } + }); + + router.get("/api/writefreely/recent", authMiddleware, async (req, res) => { + try { + const coll = (req.query.collection || WF_COLL() || "").toString(); + if (!coll) return res.json({ error: "collection alias required" }); + const data = await wf(`/api/collections/${encodeURIComponent(coll)}/posts?page=1`, { noAuth: true }); + const posts = data?.posts || data || []; + res.json({ + collection: coll, + posts: (Array.isArray(posts) ? posts : []).slice(0, 10).map((p) => ({ + id: p.id, + slug: p.slug, + title: p.title || "(untitled)", + created: p.created, + views: p.views, + url: `${WF_URL()}/${coll}/${p.slug}`, + })), + }); + } catch (err) { + res.json({ error: err.message }); + } + }); + + return router; +} diff --git a/bundles/writefreely/panel/writefreely.js b/bundles/writefreely/panel/writefreely.js new file mode 100644 index 0000000..d4b933c --- /dev/null +++ b/bundles/writefreely/panel/writefreely.js @@ -0,0 +1,149 @@ +/** + * Crow's Nest Panel — WriteFreely: status + recent posts. + * XSS-safe (textContent + createElement only). + */ + +export default { + id: "writefreely", + name: "WriteFreely", + icon: "file-text", + route: "/dashboard/writefreely", + navOrder: 71, + category: "federated-social", + + async handler(req, res, { layout }) { + const content = ` + +
+

WriteFreely federated blog

+ +
+

Status

+
Loading…
+
+ +
+

Recent Posts

+
Loading…
+
+ +
+

Notes

+
    +
  • WriteFreely has no comment system or likes. Engagement lives on the Mastodon / GoToSocial side.
  • +
  • Posts not in a collection stay as private drafts. Publish via wf_publish_post.
  • +
  • Actor signing keys live in ~/.crow/writefreely/. Preserve via scripts/backup.sh.
  • +
+
+
+ + `; + res.send(layout({ title: "WriteFreely", content })); + }, +}; + +function script() { + return ` + function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); } + function row(label, value) { + const r = document.createElement('div'); r.className = 'wf-row'; + const b = document.createElement('b'); b.textContent = label; + const s = document.createElement('span'); s.textContent = value == null ? '—' : String(value); + r.appendChild(b); r.appendChild(s); return r; + } + function err(msg) { const d = document.createElement('div'); d.className = 'np-error'; d.textContent = msg; return d; } + + async function loadStatus() { + const el = document.getElementById('wf-status'); clear(el); + try { + const res = await fetch('/api/writefreely/status'); + const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + const card = document.createElement('div'); card.className = 'wf-card'; + card.appendChild(row('Instance', d.instance_url)); + card.appendChild(row('Authenticated', d.authenticated_as ? '@' + d.authenticated_as : '(no token — set WF_ACCESS_TOKEN)')); + card.appendChild(row('Collections', d.collections.length)); + card.appendChild(row('Default collection', d.default_collection || '(none set)')); + if (d.collections.length) { + const list = document.createElement('div'); list.className = 'wf-coll-list'; + for (const c of d.collections) { + const chip = document.createElement('div'); chip.className = 'wf-coll-chip'; + const al = document.createElement('b'); al.textContent = c.alias; + const ti = document.createElement('span'); ti.className = 'wf-coll-title'; ti.textContent = c.title || ''; + const pc = document.createElement('span'); pc.className = 'wf-coll-count'; pc.textContent = (c.posts || 0) + ' posts'; + chip.appendChild(al); chip.appendChild(ti); chip.appendChild(pc); + list.appendChild(chip); + } + card.appendChild(list); + } + el.appendChild(card); + } catch (e) { el.appendChild(err('Cannot reach WriteFreely.')); } + } + + async function loadPosts() { + const el = document.getElementById('wf-posts'); clear(el); + try { + const res = await fetch('/api/writefreely/recent'); + const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + if (!d.posts || d.posts.length === 0) { + const i = document.createElement('div'); i.className = 'np-idle'; + i.textContent = 'No posts in ' + (d.collection || '(no collection)') + ' yet.'; + el.appendChild(i); return; + } + for (const p of d.posts) { + const c = document.createElement('div'); c.className = 'wf-post'; + const h = document.createElement('div'); h.className = 'wf-post-head'; + const t = document.createElement('b'); t.textContent = p.title; + h.appendChild(t); + const when = document.createElement('span'); when.className = 'wf-post-when'; + when.textContent = new Date(p.created).toLocaleDateString(); + h.appendChild(when); + c.appendChild(h); + const m = document.createElement('div'); m.className = 'wf-post-meta'; + m.textContent = 'slug ' + p.slug + ' \u2022 views ' + (p.views || 0); + c.appendChild(m); + el.appendChild(c); + } + } catch (e) { el.appendChild(err('Cannot load posts: ' + e.message)); } + } + + loadStatus(); + loadPosts(); + `; +} + +function styles() { + return ` + .wf-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; } + .wf-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; } + .wf-section { margin-bottom: 1.8rem; } + .wf-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase; + letter-spacing: 0.05em; margin: 0 0 0.7rem; } + .wf-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 10px; padding: 1rem; } + .wf-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); } + .wf-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 140px; } + .wf-coll-list { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .6rem; } + .wf-coll-chip { display: flex; align-items: center; gap: .5rem; background: var(--crow-bg); + border: 1px solid var(--crow-border); border-radius: 6px; padding: .3rem .6rem; + font-size: .85rem; } + .wf-coll-chip b { color: var(--crow-accent); font-family: ui-monospace, monospace; } + .wf-coll-title { color: var(--crow-text-primary); } + .wf-coll-count { color: var(--crow-text-muted); font-size: .75rem; } + .wf-post { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 10px; padding: .7rem 1rem; margin-bottom: .5rem; } + .wf-post-head { display: flex; justify-content: space-between; margin-bottom: .2rem; } + .wf-post-head b { color: var(--crow-text-primary); font-size: .9rem; } + .wf-post-when { font-size: .75rem; color: var(--crow-text-muted); font-family: ui-monospace, monospace; } + .wf-post-meta { font-size: .75rem; color: var(--crow-text-muted); } + .wf-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; } + .wf-notes li { margin-bottom: .3rem; } + .wf-notes code { font-family: ui-monospace, monospace; background: var(--crow-bg); + padding: 1px 4px; border-radius: 3px; font-size: .8em; } + .np-idle, .np-loading { color: var(--crow-text-muted); font-size: 0.9rem; padding: 1rem; + background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + .np-error { color: #ef4444; font-size: 0.9rem; padding: 1rem; + background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + `; +} diff --git a/bundles/writefreely/scripts/backup.sh b/bundles/writefreely/scripts/backup.sh new file mode 100755 index 0000000..bfbda6d --- /dev/null +++ b/bundles/writefreely/scripts/backup.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# WriteFreely backup — SQLite online backup + keys + uploads. +set -euo pipefail + +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/writefreely" +DATA_DIR="${WF_DATA_DIR:-$HOME/.crow/writefreely}" + +mkdir -p "$BACKUP_ROOT" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +if docker ps --format '{{.Names}}' | grep -qw crow-writefreely; then + docker exec crow-writefreely sqlite3 /go/keys/writefreely.db \ + ".backup /go/keys/wf-backup-${STAMP}.db" + docker cp "crow-writefreely:/go/keys/wf-backup-${STAMP}.db" "$WORK/writefreely.db" + docker exec crow-writefreely rm "/go/keys/wf-backup-${STAMP}.db" +fi + +# Ed25519 actor keys + config must be preserved for federation identity +tar --exclude 'writefreely.db' --exclude 'writefreely.db-wal' --exclude 'writefreely.db-shm' \ + -C "$DATA_DIR" -cf "$WORK/keys-and-config.tar" . + +OUT="${BACKUP_ROOT}/writefreely-${STAMP}.tar.zst" +if command -v zstd >/dev/null 2>&1; then + tar -C "$WORK" -cf - . | zstd -T0 -19 -o "$OUT" +else + OUT="${BACKUP_ROOT}/writefreely-${STAMP}.tar.gz" + tar -C "$WORK" -czf "$OUT" . +fi +echo "wrote $OUT ($(du -h "$OUT" | cut -f1))" +echo "NOTE: actor signing keys are in the backup. Restoring to a new" +echo " domain WILL NOT preserve federation identity — remote servers" +echo " identify you by the {host, actor_key} pair." diff --git a/bundles/writefreely/scripts/post-install.sh b/bundles/writefreely/scripts/post-install.sh new file mode 100755 index 0000000..af669a0 --- /dev/null +++ b/bundles/writefreely/scripts/post-install.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# WriteFreely bundle post-install hook. +set -euo pipefail + +BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${BUNDLE_DIR}/.env" +if [ -f "$ENV_FILE" ]; then + set -a; . "$ENV_FILE"; set +a +fi + +if ! docker inspect crow-writefreely --format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw crow-federation; then + echo "WARN: crow-writefreely is not on crow-federation — Caddy can't reach it by service name" >&2 +fi + +cat <}", + upstream: "writefreely:8080", + profile: "activitypub" + } + 2. Open https://${WF_HOST:-}/ and create the admin account + (web UI only — WriteFreely has no CLI bootstrap) + 3. Generate an API token: + curl -X POST https://${WF_HOST:-}/api/auth/login \\ + -H 'Content-Type: application/json' \\ + -d '{"alias":"","pass":""}' + Paste the access_token into .env as WF_ACCESS_TOKEN, then restart + the MCP server. + 4. List collections: + wf_list_collections + Pick your primary alias and set WF_COLLECTION_ALIAS in .env for + single-arg wf_create_post. + +EOF diff --git a/bundles/writefreely/server/index.js b/bundles/writefreely/server/index.js new file mode 100644 index 0000000..a02d973 --- /dev/null +++ b/bundles/writefreely/server/index.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +/** + * WriteFreely MCP Server — stdio transport entry point + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createWritefreelyServer } from "./server.js"; + +const server = await createWritefreelyServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/bundles/writefreely/server/server.js b/bundles/writefreely/server/server.js new file mode 100644 index 0000000..44831f6 --- /dev/null +++ b/bundles/writefreely/server/server.js @@ -0,0 +1,424 @@ +/** + * WriteFreely MCP Server + * + * WriteFreely is a publish-oriented ActivityPub server — the tool + * surface is intentionally narrower than GoToSocial's. WriteFreely does + * not moderate remote content (inbound federation is limited to follow + * events), so the block_domain / defederate / import_blocklist verbs + * in the plan's moderation taxonomy are omitted here. Outbound + * federation is governed by the federation.* fields in config.ini which + * operators edit directly. + * + * Tools: + * wf_status — health, site config, authenticated user + * wf_list_collections — blogs owned by the authenticated user + * wf_create_post — draft or published, optional collection + * wf_update_post — edit an existing post + * wf_publish_post — move a draft into a collection (public) + * wf_unpublish_post — move a collection post back to drafts + * wf_delete_post — destroy a post (destructive, confirmed) + * wf_list_posts — list posts in a collection + * wf_get_post — fetch a single post + * wf_export_posts — dump authenticated user's posts as JSON + * + * Rate limiting: content-producing verbs are wrapped with the shared + * token bucket (matches F.1 pattern, with installed-mode fallback). + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const WF_URL = (process.env.WF_URL || "http://writefreely:8080").replace(/\/+$/, ""); +const WF_ACCESS_TOKEN = process.env.WF_ACCESS_TOKEN || ""; +const WF_COLLECTION_ALIAS = process.env.WF_COLLECTION_ALIAS || ""; + +let wrapRateLimited = null; +let getDb = null; + +async function loadSharedDeps() { + try { + const rl = await import("../../../servers/shared/rate-limiter.js"); + wrapRateLimited = rl.wrapRateLimited; + } catch { + wrapRateLimited = () => (_, h) => h; + } + try { + const db = await import("../../../servers/db.js"); + getDb = db.createDbClient; + } catch { + getDb = null; + } +} + +async function wfFetch(path, { method = "GET", body, noAuth } = {}) { + const url = `${WF_URL}${path}`; + const headers = { "Content-Type": "application/json", Accept: "application/json" }; + if (!noAuth && WF_ACCESS_TOKEN) { + // WriteFreely accepts Bearer OR Authorization: Token. Bearer is newer + // and works with 0.14+. + headers.Authorization = `Token ${WF_ACCESS_TOKEN}`; + } + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), 15_000); + try { + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: ctl.signal, + }); + const text = await res.text(); + if (!res.ok) { + const snippet = text.slice(0, 500); + if (res.status === 401) { + throw new Error( + `WriteFreely auth failed (401). Set WF_ACCESS_TOKEN (obtain via POST /api/auth/login).`, + ); + } + if (res.status === 404) { + throw new Error(`WriteFreely 404: ${path}${snippet ? " — " + snippet : ""}`); + } + throw new Error(`WriteFreely ${res.status} ${res.statusText}${snippet ? " — " + snippet : ""}`); + } + if (!text) return {}; + try { + const parsed = JSON.parse(text); + // WriteFreely wraps responses in { code, data } — unwrap when present + return parsed?.data !== undefined && parsed?.code ? parsed.data : parsed; + } catch { + return { raw: text }; + } + } catch (err) { + if (err.name === "AbortError") throw new Error(`WriteFreely request timed out: ${path}`); + if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) { + throw new Error( + `Cannot reach WriteFreely at ${WF_URL}. Verify the container is on the crow-federation network (docker ps | grep crow-writefreely).`, + ); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +function resolveCollection(alias) { + return alias || WF_COLLECTION_ALIAS || null; +} + +export async function createWritefreelyServer(options = {}) { + await loadSharedDeps(); + + const server = new McpServer( + { name: "crow-writefreely", version: "1.0.0" }, + { instructions: options.instructions }, + ); + + const limiter = wrapRateLimited + ? wrapRateLimited({ db: getDb ? getDb() : null }) + : (_, h) => h; + + // --- wf_status --- + server.tool( + "wf_status", + "Report WriteFreely instance health: reachability, federation status, authenticated user, collection count.", + {}, + async () => { + try { + // Unauthenticated sanity check — GET / returns HTML; we hit a + // known JSON endpoint instead. + const me = WF_ACCESS_TOKEN ? await wfFetch("/api/me").catch(() => null) : null; + const colls = WF_ACCESS_TOKEN + ? await wfFetch("/api/me/collections").catch(() => []) + : []; + return { + content: [{ + type: "text", + text: JSON.stringify({ + instance_url: WF_URL, + has_access_token: Boolean(WF_ACCESS_TOKEN), + authenticated_as: me?.username || null, + collections: Array.isArray(colls) + ? colls.map((c) => ({ alias: c.alias, title: c.title, visibility: c.visibility })) + : [], + default_collection: WF_COLLECTION_ALIAS || null, + }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- wf_list_collections --- + server.tool( + "wf_list_collections", + "List blog collections owned by the authenticated user.", + {}, + async () => { + try { + if (!WF_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: WF_ACCESS_TOKEN required." }] }; + } + const colls = await wfFetch("/api/me/collections"); + return { + content: [{ + type: "text", + text: JSON.stringify( + (colls || []).map((c) => ({ + alias: c.alias, + title: c.title, + description: c.description, + visibility: c.visibility, + views: c.views, + post_count: c.total_posts, + })), + null, 2, + ), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- wf_create_post --- + server.tool( + "wf_create_post", + "Create a post. If collection is omitted, the post is a draft (anonymous post, not federated until published to a collection). Rate-limited: 10/hour.", + { + title: z.string().max(500).optional().describe("Post title (shown at top; WriteFreely also extracts from first # heading if omitted)."), + body: z.string().min(1).max(500_000).describe("Post body in Markdown. WriteFreely renders with its own extensions."), + collection: z.string().max(200).optional().describe("Collection alias (blog) to publish to. If absent, post is a private draft."), + font: z.enum(["norm", "serif", "sans", "mono", "wrap", "code"]).optional().describe("WriteFreely typography preset."), + language: z.string().length(2).optional().describe("ISO 639-1 language code."), + rtl: z.boolean().optional().describe("Right-to-left layout."), + created: z.string().max(30).optional().describe("ISO timestamp override (useful for imports)."), + }, + limiter("wf_create_post", async (args) => { + try { + if (!WF_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: WF_ACCESS_TOKEN required to create posts." }] }; + } + const body = { + body: args.body, + ...(args.title ? { title: args.title } : {}), + ...(args.font ? { font: args.font } : {}), + ...(args.language ? { lang: args.language } : {}), + ...(args.rtl != null ? { rtl: args.rtl } : {}), + ...(args.created ? { created: args.created } : {}), + }; + const coll = resolveCollection(args.collection); + const path = coll ? `/api/collections/${encodeURIComponent(coll)}/posts` : "/api/posts"; + const post = await wfFetch(path, { method: "POST", body }); + return { + content: [{ + type: "text", + text: JSON.stringify({ + id: post.id, + slug: post.slug, + collection: coll, + url: coll ? `${WF_URL}/${coll}/${post.slug}` : `${WF_URL}/${post.id}`, + token: post.token, + created: post.created, + published: Boolean(coll), + }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- wf_update_post --- + server.tool( + "wf_update_post", + "Edit an existing post by ID. Rate-limited: 20/hour.", + { + post_id: z.string().min(1).max(50), + title: z.string().max(500).optional(), + body: z.string().max(500_000).optional(), + font: z.enum(["norm", "serif", "sans", "mono", "wrap", "code"]).optional(), + language: z.string().length(2).optional(), + rtl: z.boolean().optional(), + }, + limiter("wf_update_post", async ({ post_id, ...rest }) => { + try { + if (!WF_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: WF_ACCESS_TOKEN required." }] }; + } + const body = {}; + for (const [k, v] of Object.entries(rest)) { + if (v !== undefined) body[k === "language" ? "lang" : k] = v; + } + const post = await wfFetch(`/api/posts/${encodeURIComponent(post_id)}`, { method: "POST", body }); + return { + content: [{ + type: "text", + text: JSON.stringify({ id: post.id, slug: post.slug, updated: post.updated }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- wf_publish_post --- + server.tool( + "wf_publish_post", + "Move a draft post into a collection (blog), making it public and federated over ActivityPub. Rate-limited: 10/hour.", + { + post_id: z.string().min(1).max(50), + collection: z.string().min(1).max(200).describe("Collection alias to publish to."), + }, + limiter("wf_publish_post", async ({ post_id, collection }) => { + try { + if (!WF_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: WF_ACCESS_TOKEN required." }] }; + } + // WriteFreely publishes by POSTing a { id } into the collection + const res = await wfFetch(`/api/collections/${encodeURIComponent(collection)}/collect`, { + method: "POST", + body: [{ id: post_id }], + }); + return { content: [{ type: "text", text: JSON.stringify({ published: true, collection, post_id, response: res }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- wf_unpublish_post --- + server.tool( + "wf_unpublish_post", + "Move a collection post back to drafts (removes it from the public blog and federation). The post is NOT deleted.", + { post_id: z.string().min(1).max(50) }, + limiter("wf_unpublish_post", async ({ post_id }) => { + try { + if (!WF_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: WF_ACCESS_TOKEN required." }] }; + } + // Unpublish = update post with collection: null + const res = await wfFetch(`/api/posts/${encodeURIComponent(post_id)}`, { + method: "POST", + body: { crosspost: false, collection: null }, + }); + return { content: [{ type: "text", text: JSON.stringify({ unpublished: true, post_id, response: res }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- wf_delete_post --- + server.tool( + "wf_delete_post", + "Permanently delete a post. Destructive — the post is gone from the local instance; federated copies on remote servers may persist.", + { + post_id: z.string().min(1).max(50), + confirm: z.literal("yes"), + }, + limiter("wf_delete_post", async ({ post_id }) => { + try { + if (!WF_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: WF_ACCESS_TOKEN required." }] }; + } + await wfFetch(`/api/posts/${encodeURIComponent(post_id)}`, { method: "DELETE" }); + return { content: [{ type: "text", text: JSON.stringify({ deleted: true, post_id }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- wf_list_posts --- + server.tool( + "wf_list_posts", + "List posts in a collection (public endpoint — no auth required). Paginated.", + { + collection: z.string().max(200).optional().describe("Collection alias. Defaults to WF_COLLECTION_ALIAS."), + page: z.number().int().min(1).max(1000).optional(), + }, + async ({ collection, page }) => { + try { + const coll = resolveCollection(collection); + if (!coll) { + return { content: [{ type: "text", text: "Error: collection alias required (set WF_COLLECTION_ALIAS or pass explicitly)." }] }; + } + const res = await wfFetch( + `/api/collections/${encodeURIComponent(coll)}/posts?page=${page ?? 1}`, + { noAuth: true }, + ); + const posts = res?.posts || res || []; + const summary = (Array.isArray(posts) ? posts : []).map((p) => ({ + id: p.id, + slug: p.slug, + title: p.title, + created: p.created, + url: `${WF_URL}/${coll}/${p.slug}`, + views: p.views, + })); + return { content: [{ type: "text", text: JSON.stringify({ collection: coll, count: summary.length, posts: summary }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- wf_get_post --- + server.tool( + "wf_get_post", + "Fetch a single post's full body + metadata. Works for public posts without auth.", + { + post_id: z.string().max(50).optional(), + collection: z.string().max(200).optional(), + slug: z.string().max(200).optional(), + }, + async ({ post_id, collection, slug }) => { + try { + let path; + if (post_id) { + path = `/api/posts/${encodeURIComponent(post_id)}`; + } else if (collection && slug) { + path = `/api/collections/${encodeURIComponent(collection)}/posts/${encodeURIComponent(slug)}`; + } else { + return { content: [{ type: "text", text: "Error: provide either post_id OR (collection + slug)." }] }; + } + const post = await wfFetch(path, { noAuth: !WF_ACCESS_TOKEN }); + return { content: [{ type: "text", text: JSON.stringify(post, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- wf_export_posts --- + server.tool( + "wf_export_posts", + "Export all posts owned by the authenticated user as JSON. Useful for migration and backup.", + { format: z.enum(["json", "csv"]).optional().describe("Default json. csv includes title/body/collection/created.") }, + async ({ format }) => { + try { + if (!WF_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: WF_ACCESS_TOKEN required." }] }; + } + const path = `/api/me/posts${format === "csv" ? "?format=csv" : ""}`; + const data = await wfFetch(path); + if (format === "csv") { + return { content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] }; + } + const summary = Array.isArray(data) + ? data.map((p) => ({ id: p.id, slug: p.slug, title: p.title, collection: p.collection?.alias, created: p.created, views: p.views })) + : data; + return { content: [{ type: "text", text: JSON.stringify({ count: Array.isArray(data) ? data.length : null, posts: summary }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + return server; +} diff --git a/bundles/writefreely/skills/writefreely.md b/bundles/writefreely/skills/writefreely.md new file mode 100644 index 0000000..42de710 --- /dev/null +++ b/bundles/writefreely/skills/writefreely.md @@ -0,0 +1,105 @@ +--- +name: writefreely +description: WriteFreely — federated blogging via ActivityPub. Long-form posts with Markdown, publish-drafts workflow, fediverse reach. +triggers: + - "writefreely" + - "blog post" + - "long form" + - "publish to fediverse" + - "federated blog" + - "draft post" + - "medium alternative" +tools: + - wf_status + - wf_list_collections + - wf_create_post + - wf_update_post + - wf_publish_post + - wf_unpublish_post + - wf_delete_post + - wf_list_posts + - wf_get_post + - wf_export_posts +--- + +# WriteFreely — federated blog + +WriteFreely is a minimalist ActivityPub blogging platform — single-binary, single-SQLite footprint, no comment system, no likes, no analytics. Posts are Markdown. Publish to a "collection" (blog) and the post federates to any Mastodon / GoToSocial / other ActivityPub account that follows the blog's actor. + +Crow's own `crow-blog` and WriteFreely overlap conceptually; the difference is federation: `crow-blog` is private-by-default (share via invite), WriteFreely is public-federated. + +## Prerequisites + +1. **Caddy must be installed** (declared dependency; install refused otherwise). +2. **Subdomain with A/AAAA record.** ActivityPub actors are URL-keyed; `example.com/blog` does not federate. Use `blog.example.com` or similar. +3. Ports 80/443 reachable for ACME. Hardware gate is cheap for this one — 256 MB min RAM. + +## First-run setup + +The container seeds a default config on first boot. To finish setup: + +1. `caddy_add_federation_site { "domain": "blog.example.com", "upstream": "writefreely:8080", "profile": "activitypub" }` +2. Open `https://blog.example.com/` and create the admin account via the web UI. There is **no** CLI bootstrap — WriteFreely requires a human at the browser for initial admin creation. +3. Generate an API token: + ```bash + curl -X POST https://blog.example.com/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"alias":"","pass":""}' + ``` + Paste the `access_token` into `.env` as `WF_ACCESS_TOKEN`. +4. Note the collection alias (shown in the web UI). Set `WF_COLLECTION_ALIAS` if you want a default. +5. Restart the MCP server so it picks up the token. + +## Common workflows + +### Draft → publish + +``` +wf_create_post { "title": "Hello fediverse", "body": "# Hi there\n\nFirst post." } +→ returns { id, slug, published: false } + +wf_publish_post { "post_id": "", "collection": "myblog" } +→ the post is now public at https://blog.example.com/myblog/ + and federates to any remote follower +``` + +Published posts show on the blog's collection page and in the collection's ActivityPub outbox. Unpublish (without deleting) via `wf_unpublish_post` — the post returns to drafts but is NOT federated out as a `Delete` activity (WriteFreely treats unpublish as "hide", not "retract"). To really retract, `wf_delete_post` (destructive). + +### Single-user mode shortcut + +If `WF_SINGLE_USER=true` and `WF_COLLECTION_ALIAS` is set, you can skip the `collection` arg on `wf_create_post` — the tool publishes straight to the default blog: + +``` +wf_create_post { "title": "Quick thought", "body": "..." } +→ published to WF_COLLECTION_ALIAS +``` + +### List + fetch + +``` +wf_list_posts { "collection": "myblog", "page": 1 } +wf_get_post { "collection": "myblog", "slug": "hello-fediverse" } +``` + +Public collection endpoints need no auth — the AI can browse public fediverse blogs via WriteFreely's API. + +### Export / backup + +``` +wf_export_posts { "format": "json" } +``` + +Dumps the authenticated user's full post set. `scripts/backup.sh` does this plus a SQLite dump. + +## What WriteFreely doesn't do + +- **No comments.** By design. Remote Mastodon replies appear in your mentions on the instance running WriteFreely's outbox, not on the blog page. +- **No moderation queue.** WriteFreely publishes outbound; inbound federation is limited to follow events. There are no `block_user` / `defederate` tools because there's nothing inbound to moderate at the post level. If a remote admin reports your instance, handle it at the Caddy layer (`caddy_remove_site` the whole domain in extreme cases). +- **No reblogs / favorites.** Posts federate out; reblogs of your posts on Mastodon don't flow back as engagement data. + +## Troubleshooting + +- **"401 auth failed"** — token expired or revoked. Re-login at `POST /api/auth/login`. +- **Publishing fails with "collection not found"** — collection alias is case-sensitive and not the human title. Get exact alias via `wf_list_collections`. +- **Post is public but doesn't appear on Mastodon** — check the blog's ActivityPub actor is reachable: `curl -H 'Accept: application/activity+json' https://blog.example.com/api/collections/` should return a JSON actor. If not, Caddy site config is wrong (missing headers). Re-run `caddy_add_federation_site` with profile activitypub. +- **Want analytics / likes / reblogs** — wrong tool. WriteFreely is a minimalist publisher. Consider GoToSocial (F.1) or Mastodon (F.7) for that behavior. diff --git a/registry/add-ons.json b/registry/add-ons.json index c18547e..19ca52e 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -3072,6 +3072,44 @@ "webUI": null, "notes": "No host port publish. Expose via Caddy after install: caddy_add_federation_site { domain, upstream: 'gotosocial:8080', profile: 'activitypub' }." }, + { + "id": "writefreely", + "name": "WriteFreely", + "description": "Lightweight, minimalist ActivityPub blogging platform. Single-binary, single-SQLite-file footprint — ideal for long-form writing federated to the fediverse.", + "type": "bundle", + "version": "1.0.0", + "author": "Crow", + "category": "federated-social", + "tags": ["activitypub", "fediverse", "blog", "long-form", "federated", "writing"], + "icon": "file-text", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["WF_URL", "WF_ACCESS_TOKEN", "WF_COLLECTION_ALIAS"] + }, + "panel": "panel/writefreely.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/writefreely.md"], + "consent_required": true, + "requires": { + "env": ["WF_HOST"], + "bundles": ["caddy"], + "min_ram_mb": 256, + "recommended_ram_mb": 512, + "min_disk_mb": 500, + "recommended_disk_mb": 5000 + }, + "env_vars": [ + { "name": "WF_HOST", "description": "Public subdomain for this WriteFreely instance.", "required": true }, + { "name": "WF_ACCESS_TOKEN", "description": "Admin API token (from POST /api/auth/login after first-run web setup).", "required": false, "secret": true }, + { "name": "WF_COLLECTION_ALIAS", "description": "Default collection (blog) alias.", "required": false }, + { "name": "WF_SINGLE_USER", "description": "Single-user mode (true) or multi-user (false).", "default": "true", "required": false } + ], + "ports": [], + "webUI": null, + "notes": "No host port publish. After install: caddy_add_federation_site { domain: WF_HOST, upstream: 'writefreely:8080', profile: 'activitypub' }. Admin account is created via the first-run web UI (no CLI bootstrap)." + }, { "id": "developer-kit", "name": "Developer Kit", diff --git a/skills/superpowers.md b/skills/superpowers.md index f4cabda..a17e36f 100644 --- a/skills/superpowers.md +++ b/skills/superpowers.md @@ -80,6 +80,7 @@ This is the master routing skill. Consult this **before every task** to determin | "organize notes", "brain dump", "sort these ideas", "help me plan from these" | "organizar notas", "ideas sueltas", "ordenar estas ideas", "aquí están mis notas" | ideation | crow-memory, crow-projects | | "schedule", "remind me", "every day at", "recurring" | "programar", "recuérdame", "cada día a las" | scheduling | crow-memory | | "toot", "post to fediverse", "follow @user@...", "mastodon", "gotosocial", "activitypub" | "publicar en fediverso", "tootear", "seguir @usuario@...", "mastodon", "gotosocial" | gotosocial | crow-gotosocial | +| "writefreely", "federated blog", "long-form post", "publish article", "blog to fediverse" | "writefreely", "blog federado", "artículo largo", "publicar al fediverso" | writefreely | crow-writefreely | | "tutor me", "teach me", "quiz me", "help me understand" | "enséñame", "explícame", "evalúame" | tutoring | crow-memory | | "wrap up", "summarize session", "what did we do" | "resumir sesión", "qué hicimos" | session-summary | crow-memory | | "change language", "speak in..." | "cambiar idioma", "háblame en..." | i18n | crow-memory |