What
When an image's source post URL is a bsky.app or X post link, look the image up on entail.dev and offer its classifier tags as prefilled suggestions in the tags field. The operator accepts or removes them before anything is saved. Nothing is written without the operator confirming.
Applies to the upload form, the image edit form, and a backfill action in admin that runs the same lookup over existing images that already have a Bluesky or X source URL.
Why
Tagging is the slowest part of uploading. Most commissions are cross-posted to Bluesky first, and Sona already stores that link in sourcePostUrl. entail.dev (a public furry image classifier for Bluesky posts, https://entail.dev/api/docs) returns tags with a confidence score per tag, plus a safe/questionable/explicit rating. That covers most of what an operator would type by hand.
The API, as verified on 2026-09-08
Spec: https://entail.dev/api/openapi.json. No API keys. Rate limited per client IP.
Bluesky posts. GET https://entail.dev/api/post?url=<bsky.app post URL>&min_confidence=0.8&wait=true
url takes the bsky.app post URL or an at:// URI directly, so Sona does not need to parse handle and rkey (the older handle + rkey form still works). min_confidence filters tags server-side; the docs recommend 0.8 for Sona. Returns one entry per image in the post: an e621-style tag list with confidence per tag, a rating (safe, questionable, explicit) with score, and a router score (furry vs artwork). A post that has not been classified yet returns 202 and gets queued; wait=true holds the request briefly for the result. Verified working on a real post, response in a few seconds.
X posts, via an allowlisted media URL. Added by the service on 2026-09-08 after the proposal below.
POST https://entail.dev/api/classify with body {"url": "https://pbs.twimg.com/media/<id>?format=jpg&name=4096x4096"}. The service validates the host against a short allowlist (pbs.twimg.com, cdn.bsky.app), does not fetch synchronously, and returns 202 with a job_id.
GET https://entail.dev/api/classify/<job_id>?wait=true polls the job. Returns the same shape as /post (router scores, rating, tags with confidence) plus content_sha256 and model versions. Never returns image bytes.
Verified end to end on a real pbs.twimg.com URL: job enqueued, finished result returned in under a minute. That test image rated explicit at 0.98 with bondage tags above 0.80, which makes it a useful fixture for the rating to NSFW suggestion mapping.
Sona's side of the X path: resolve the tweet with the existing guest token code (already used for avatars) to get the pbs.twimg.com media URL, submit it, poll. This flow is asynchronous and slower than /post, so the best-effort timeout applies to both paths.
GET /api/search?tag=&rating=&limit=&cursor=&min_confidence= (reverse lookup, tag to posts) now answers in well under a second. The earlier 60 second hangs were a missing index on the service side, since fixed. Not needed for this issue.
There is still no endpoint that accepts uploaded image bytes or an arbitrary host. Images with neither a Bluesky nor an X source URL get no suggestions.
Tag quality on a sample post
50 tags came back, all above 0.63. Nine were at or above 0.80 and all nine were correct (species, anthro, solo, medium). Below 0.70 the noise starts: objects and body-part tags that were not in the image. A default floor of 0.80 looks right, with a lower value available to the operator.
Design points to settle
- Tag translation. entail uses e621 vocabulary: underscores and parenthetical qualifiers like
digital_media_(artwork). Sona's sanitizeTag strips underscores and parentheses, which would produce digitalmediaartwork. Need a mapping step (underscore to hyphen, drop the qualifier) or a sanitizer change.
- Vocabulary filter. e621 tags are far more granular than gallery tags. Consider a category allowlist (species, colors, medium) so suggestions stay useful.
- Rating. Map explicit/questionable to the NSFW checkbox as a suggestion only, never an override.
- Multi-image posts. Pick a rule: first image, or match on hash. The classify response exposes
content_sha256, so a hash match is possible on the X path; /post returns one entry per image and the rule still needs choosing there.
- UI. Suggested tags render as deselectable chips in the tags field. This is a UI change, so a mock and approval come before implementation.
- Failure mode. entail down or slow must not block the upload. Suggestions are best effort with a short timeout on both the Bluesky lookup and the X classify job.
- X media URL resolution. The guest token code runs from each fork's Worker, so X scraping stays spread across forks rather than concentrated on the service's single IP. Keep it that way; never proxy the tweet resolution through entail.
X/Twitter posts: now in scope
Most commissions land on X first. Until 2026-09-08 entail had no way to classify them: image URL and base64 input were ruled out as unsafe, and guest token scraping of X from the service's single server IP would get that IP banned under abuse.
Proposed to the service maintainer on 2026-09-07 and accepted the same day: Sona resolves the tweet with its own guest token code and passes the pbs.twimg.com media URL. entail accepts URLs only from a short allowlist (pbs.twimg.com, cdn.bsky.app), fetches the bytes itself, caps the size, and rate limits per caller. The service shipped this on 2026-09-08 as the /classify endpoints described above.
Both paths share the suggestion UI and the tag translation step, so X support ships in this issue rather than as a follow-up. If the X path turns out to need its own round of work, split it then.
Plan (2026-09-08)
Does not depend on #288 (operator decision, 2026-09-08). #288's second PR builds a lookup panel on the same upload and edit forms with the same page-state rules; the two follow the same conventions (never overwrite, operator confirms, nothing persists until save), so whichever lands second adopts the other's primitives at merge time, but neither waits for the other.
- Server module, starts now.
src/lib/server/entail.ts: a source URL classifier (Bluesky post, X status, neither); an e621 to Sona tag translator (underscore to hyphen, strip parenthetical qualifiers, drop below the floor) that runs before sanitizeTag; a Bluesky lookup against /post; an X classify submit and poll. Plus a tweet to pbs.twimg.com media URL resolver next to the existing guest token code. All follow the avatar fetcher's pattern: short timeout, never throw, return null on failure, log status codes only, never log or store the third-party body. Unit tests on the pure parts and on the fetchers with a mocked fetch.
- Mock. Suggested tags as deselectable chips beside the tags input, and the rating as a hint beside the NSFW checkbox, never applied on its own. Both themes and mobile. Approval before any UI code.
- Endpoint, UI, and backfill. Admin endpoint mirroring the artist lookup endpoint's shape (JSON
{ imageId } or the form's source URL, admin gated by hooks, no caller-supplied host reaches entail, JSON { enabled, error } failures with the same error vocabulary, entail's own 429 passed through). A sibling component next to the tags input with its own small state model. The admin backfill action, serial with a pause between calls. The architecture diagram edit.
Decisions taken for v1: filter by the 0.80 floor only (entail returns no tag categories, so an allowlist needs a bundled vocabulary and is deferred); multi-image Bluesky posts use the first image.
Rollout
Outbound fetches from the Worker to entail.dev (one for Bluesky, a submit plus a poll for X). No new bindings, secrets, or per-fork steps. Every fork gets it on the next release with no operator action. The backfill action is operator triggered from admin, so it is opt in per fork.
Note for the architecture doc: this adds an external service dependency, so the diagram gets updated in the same change.
What
When an image's source post URL is a bsky.app or X post link, look the image up on entail.dev and offer its classifier tags as prefilled suggestions in the tags field. The operator accepts or removes them before anything is saved. Nothing is written without the operator confirming.
Applies to the upload form, the image edit form, and a backfill action in admin that runs the same lookup over existing images that already have a Bluesky or X source URL.
Why
Tagging is the slowest part of uploading. Most commissions are cross-posted to Bluesky first, and Sona already stores that link in
sourcePostUrl. entail.dev (a public furry image classifier for Bluesky posts, https://entail.dev/api/docs) returns tags with a confidence score per tag, plus a safe/questionable/explicit rating. That covers most of what an operator would type by hand.The API, as verified on 2026-09-08
Spec:
https://entail.dev/api/openapi.json. No API keys. Rate limited per client IP.Bluesky posts.
GET https://entail.dev/api/post?url=<bsky.app post URL>&min_confidence=0.8&wait=trueurltakes the bsky.app post URL or anat://URI directly, so Sona does not need to parse handle and rkey (the olderhandle+rkeyform still works).min_confidencefilters tags server-side; the docs recommend 0.8 for Sona. Returns one entry per image in the post: an e621-style tag list with confidence per tag, a rating (safe, questionable, explicit) with score, and a router score (furry vs artwork). A post that has not been classified yet returns 202 and gets queued;wait=trueholds the request briefly for the result. Verified working on a real post, response in a few seconds.X posts, via an allowlisted media URL. Added by the service on 2026-09-08 after the proposal below.
POST https://entail.dev/api/classifywith body{"url": "https://pbs.twimg.com/media/<id>?format=jpg&name=4096x4096"}. The service validates the host against a short allowlist (pbs.twimg.com, cdn.bsky.app), does not fetch synchronously, and returns 202 with ajob_id.GET https://entail.dev/api/classify/<job_id>?wait=truepolls the job. Returns the same shape as/post(router scores, rating, tags with confidence) pluscontent_sha256and model versions. Never returns image bytes.Verified end to end on a real pbs.twimg.com URL: job enqueued, finished result returned in under a minute. That test image rated explicit at 0.98 with bondage tags above 0.80, which makes it a useful fixture for the rating to NSFW suggestion mapping.
Sona's side of the X path: resolve the tweet with the existing guest token code (already used for avatars) to get the pbs.twimg.com media URL, submit it, poll. This flow is asynchronous and slower than
/post, so the best-effort timeout applies to both paths.GET /api/search?tag=&rating=&limit=&cursor=&min_confidence=(reverse lookup, tag to posts) now answers in well under a second. The earlier 60 second hangs were a missing index on the service side, since fixed. Not needed for this issue.There is still no endpoint that accepts uploaded image bytes or an arbitrary host. Images with neither a Bluesky nor an X source URL get no suggestions.
Tag quality on a sample post
50 tags came back, all above 0.63. Nine were at or above 0.80 and all nine were correct (species, anthro, solo, medium). Below 0.70 the noise starts: objects and body-part tags that were not in the image. A default floor of 0.80 looks right, with a lower value available to the operator.
Design points to settle
digital_media_(artwork). Sona'ssanitizeTagstrips underscores and parentheses, which would producedigitalmediaartwork. Need a mapping step (underscore to hyphen, drop the qualifier) or a sanitizer change.content_sha256, so a hash match is possible on the X path;/postreturns one entry per image and the rule still needs choosing there.X/Twitter posts: now in scope
Most commissions land on X first. Until 2026-09-08 entail had no way to classify them: image URL and base64 input were ruled out as unsafe, and guest token scraping of X from the service's single server IP would get that IP banned under abuse.
Proposed to the service maintainer on 2026-09-07 and accepted the same day: Sona resolves the tweet with its own guest token code and passes the pbs.twimg.com media URL. entail accepts URLs only from a short allowlist (pbs.twimg.com, cdn.bsky.app), fetches the bytes itself, caps the size, and rate limits per caller. The service shipped this on 2026-09-08 as the
/classifyendpoints described above.Both paths share the suggestion UI and the tag translation step, so X support ships in this issue rather than as a follow-up. If the X path turns out to need its own round of work, split it then.
Plan (2026-09-08)
Does not depend on #288 (operator decision, 2026-09-08). #288's second PR builds a lookup panel on the same upload and edit forms with the same page-state rules; the two follow the same conventions (never overwrite, operator confirms, nothing persists until save), so whichever lands second adopts the other's primitives at merge time, but neither waits for the other.
src/lib/server/entail.ts: a source URL classifier (Bluesky post, X status, neither); an e621 to Sona tag translator (underscore to hyphen, strip parenthetical qualifiers, drop below the floor) that runs beforesanitizeTag; a Bluesky lookup against/post; an X classify submit and poll. Plus a tweet to pbs.twimg.com media URL resolver next to the existing guest token code. All follow the avatar fetcher's pattern: short timeout, never throw, return null on failure, log status codes only, never log or store the third-party body. Unit tests on the pure parts and on the fetchers with a mocked fetch.{ imageId }or the form's source URL, admin gated by hooks, no caller-supplied host reaches entail, JSON{ enabled, error }failures with the same error vocabulary, entail's own 429 passed through). A sibling component next to the tags input with its own small state model. The admin backfill action, serial with a pause between calls. The architecture diagram edit.Decisions taken for v1: filter by the 0.80 floor only (entail returns no tag categories, so an allowlist needs a bundled vocabulary and is deferred); multi-image Bluesky posts use the first image.
Rollout
Outbound fetches from the Worker to entail.dev (one for Bluesky, a submit plus a poll for X). No new bindings, secrets, or per-fork steps. Every fork gets it on the next release with no operator action. The backfill action is operator triggered from admin, so it is opt in per fork.
Note for the architecture doc: this adds an external service dependency, so the diagram gets updated in the same change.