Mastodon publishing
Status: mock-tested text adapter with one live receipt: a text post sent through ReachWeaver on 2026-10-11, recorded in mastodon-2026-10-11.json. Everything below was checked against
reachweaver/publishers.py,connections.py,policy.py,accounts.pyand the offline tests; the receipt proves one real send, not the failure and rate-limit behaviour, which stays mock-tested. The two server pages it relies on arePOST /api/v1/statusesand media upload.
Nothing here changes the human approval, the dry-run default, the emergency pause, credential isolation or ambiguous-send reconciliation. Live sending still needs an approved post, publish --live, REACHWEAVER_ALLOW_LIVE=1 and REACHWEAVER_ENV=prod.
Account configuration (no secrets)
Section titled “Account configuration (no secrets)”{"id": "my-mastodon", "platform": "mastodon", "env_prefix": "REACHWEAVER_MASTODON", "server": "https://mastodon.social", "handle": "example", "visibility": "public"}| Field | Rule |
|---|---|
server |
Required. A public https host. Every limit and every request is for this server only. |
handle |
The local username (example), or example@your-server when the part after @ is exactly the configured server. A handle on another server is refused with mastodon_expected_handle_must_be_local_to_configured_server by connect-check --online and by the live identity preflight, before any request (the account file itself still loads). |
visibility |
Optional: public (default), unlisted or private. direct (a direct message) is forbidden. |
language |
Optional lowercase ISO 639-1 code such as en. |
spoiler_text |
Optional, 1 to 200 printable characters. |
max_characters |
Optional integer. It can only lower the conservative 450 limit, never raise it: an instance that allows 500 still gets 450 unless you pin less. |
max_media_attachments, image_size_limit, media_description_limit |
Optional. Can only lower the defaults (4 images, 16,000,000 bytes, 1500 characters). |
The credential is <env_prefix>_TOKEN, an instance user token with write:statuses. Set it in your own terminal, never in a chat or a file in the repository (see account onboarding).
Server capability probe
Section titled “Server capability probe”connect-check --account my-mastodon --online makes two read-only requests:
GET <server>/api/v1/accounts/verify_credentialswith your token. The token’s username must equalhandle, otherwiseidentity_mismatch. It proves who the token belongs to; it does not prove thewrite:statusesscope.GET <server>/api/v2/instancewith no token (Mastodon 4.0 or later). It reports the instance’s ownmax_characters,max_media_attachments,characters_reserved_per_url, image and video limits and the number of accepted MIME types, next to youreffective_text_limit.
The command exits 2 when your pinned max_characters exceeds the instance value, or when the effective limit does (for example an instance that allows 300 while you have not pinned anything: the default of 450 would be too long). Nothing is saved; you edit the local account file yourself.
Formats and limits
Section titled “Formats and limits”- Text. Counted in code points, with each URL counted as at least 23 characters, against the effective limit above. A URL counted as 23 can never make a post look shorter than it is.
- Visibility, language, content warning. From the account configuration, as in the table.
- Images. PNG, JPEG or WebP, at most 4 per post, each at most 16,000,000 bytes, with alt text (at most 1500 characters) that you write. GIF and video are not supported. The adapter polls an unprocessed upload a bounded number of times. Image sending is off: the capability registry lists no live image format, so
publish --liverefuses an image post before anything is claimed. The offline tools still work:media-check,media-attachand the approval binding of the file’s SHA-256. - Replies. Text only, on your own server only (
reply_server_mismatchotherwise).
What the approver sees and what is bound
Section titled “What the approver sees and what is bound”show POST_ID is the offline preview and makes no request. For Mastodon its platform_preview lists the exact server, handle, visibility, language, spoiler_text, counted_length, text_limit and the idempotency_key.
approve binds the account, platform, exact text, schedule, source URL and campaign, plus the SHA-256 of any attached image, and the account’s visibility, language and spoiler_text (a fingerprint of the three effective values, visibility defaulting to public, under the tag approval/options-1; approve prints Bound send options: visibility=... language=... spoiler_text=...). If you change any of them in the account file after approval, publish --live refuses the post before any request with approval_send_options_changed_reapproval_required; it stays approved, and either restoring the approved values or re-approving (unapprove, show, approve) lets it go. An unchanged account sends exactly the bound values. show prints the values and whether they still match (approval.send_options). approve refuses a Mastodon post whose account entry cannot be loaded (approval_send_options_unavailable), because there would be nothing to bind.
Upgrade note. A Mastodon post approved with ReachWeaver 0.12.0 or earlier carries no such fingerprint and is not grandfathered: publish --live refuses it with approval_send_options_missing_reapproval_required, and health, queue-health and the dry run list it. Re-approve it once: unapprove POST_ID, show POST_ID, approve POST_ID (then schedule again if it was scheduled). Replies were already covered: their visibility is part of the bound reply target.
How a send ends
Section titled “How a send ends”A send is POST <server>/api/v1/statuses (form-encoded) with Idempotency-Key set to the local post id. Mastodon remembers an idempotency key for about an hour, so beyond that window the duplicate guard is ReachWeaver’s own rule: an uncertain send goes to needs_review and is never retried automatically.
| Situation | Result |
|---|---|
Checks before the post is claimed: a missing <env_prefix>_TOKEN (SKIPPED ... configure environment variables), text over the limit (SKIPPED ... failed preflight check), a failed identity preflight (BLOCKED; a 429 there is retried at most twice with jitter, then a cooldown is recorded) |
Exit 2; nothing is claimed and the post stays approved. A direct visibility is refused when the account file is loaded; a handle on another server is refused at the identity preflight (BLOCKED) before any request. |
| An attached image file that changed since approval | Caught before the claim (BLOCKED, post stays approved). The adapter checks the bytes again just before the write marker; a change there is needs_review with class certain_not_sent, which is guidance only. |
401, 403, 4xx other than 429, 5xx, a timeout or a network error after the request was handed over |
needs_review, class ambiguous, no automatic retry. |
429 |
needs_review, class likely_not_created, and a per-account cooldown computed from X-RateLimit-Reset (default 15 minutes) so the next send waits. |
A server reply without an id, without an https link, naming another account, or showing another visibility than requested |
needs_review with a mastodon_..._uncertain code. |
| Success | PUBLISHED POST_ID: URL. The id and the link come from the server’s reply. |
Errors carry only a fixed code such as http_401: never the URL, the headers, the token or the response body. After needs_review, look at the account in the Mastodon web UI, then reconcile (see troubleshooting).
Local testing steps (no network, no token)
Section titled “Local testing steps (no network, no token)”python3 -m unittest discover -s tests -p 'test_bluesky_mastodon.py' -vpython3 -m unittest discover -s tests -p 'test_mastodon_*.py' -vpython3 -m unittest discover -s tests -p 'test_rate_limits.py' -vpython3 -m unittest discover -s tests -p 'test_fault_injection.py' -vThe provider fixtures are synthetic and live in tests/fixtures/providers/mastodon/ (a 429 with X-RateLimit-Reset, an unprocessed 202 upload, a partial 206 poll, a ready 200 poll, two upload 429s, and 401/403 replies to verify_credentials and to the status create). tests/test_mastodon_auth_fixtures.py replays the 401/403 fixtures: at the identity preflight they block the post before the claim with one request and no retry; on the status create they end in needs_review and are never resent. It also checks that a handle on another server is refused before any request. tests/test_rate_limits.py::test_ambiguous_codes_never_retried sends 401, 403, 426, 500, 502, 503, a timeout, a malformed reply and an unexpected exception through the real CLI and requires one create request, needs_review and no resend. For a real first send use the owner-only runbook pattern in the Bluesky pilot: a throwaway account you own, one harmless post, your own terminal.
