How to generate WCAG-compliant ALT text for WordPress images without sending them to a vendor's black-box API A developer built a WordPress plugin, Alt Text BYOK, that generates WCAG-compliant alt text for images using a vision-capable LLM while keeping images private by allowing users to bring their own API key and endpoint. The plugin supports any OpenAI-compatible chat/completions endpoint, ensuring images are sent directly to the user's chosen provider, and includes prompt engineering details to produce concise, descriptive alt text. If you've ever tried to fix accessibility on an old WordPress site, you know the drill: hundreds of images in the Media Library, most with empty alt attributes, and a WCAG 2.1 audit or a client demanding one breathing down your neck. Writing alt text by hand for 400 images is not a fun Tuesday. Every "AI alt text" SaaS I looked at wanted a monthly subscription, routed my images through their own servers, and gave me zero control over which model actually looked at the picture. This post is about the plugin I built to fix that for my own sites, and the handful of implementation details that turned out to matter more than expected. WCAG 2.1 Success Criterion 1.1.1 requires non-text content to have a text alternative. In WordPress terms: every attachment post of MIME type image should have wp attachment image alt set to something meaningful, not "IMG 4821.jpg" and not empty. Doing this with a vision-capable LLM is trivial in principle — send the image, ask for a short description, save it as the alt attribute. The part that's not trivial, if you don't want another recurring SaaS bill and don't want to hand a third party your whole media library, is: whose API key, which model, and where does the image actually go. The plugin Alt Text BYOK doesn't call any server of mine. It calls whatever OpenAI-compatible chat/completions endpoint you configure, with your own API key. That's the entire trust model: your images go from your WordPress install directly to the provider you already chose OpenAI, or any of the growing list of OpenAI-compatible vision endpoints , and nowhere else. The settings are deliberately just four fields: function atbyok default settings { return array 'api base' = 'https://api.openai.com/v1', 'api key' = '', 'model' = 'gpt-4o-mini', 'language' = 'English', 'overwrite existing' = '0', 'license key' = '', ; } api base is the detail that matters most for portability: it's not hardcoded to OpenAI. Point it at any provider that speaks the same chat/completions shape with image content parts, and it works. That includes several free-tier vision models if you want to run the whole thing at zero cost. Nothing exotic — a single chat/completions request with a multimodal content array text instruction + image url : php function atbyok call vision api $image url, $settings { $endpoint = trailingslashit $settings 'api base' . 'chat/completions'; $prompt = sprintf 'Describe this image in %s as a concise, descriptive ALT text for web accessibility. ' . 'Maximum 125 characters. Do not start with "image of" or "picture of". ' . 'Reply with the ALT text only, no quotes, no extra commentary.', $settings 'language' ; $body = array 'model' = $settings 'model' , 'messages' = array array 'role' = 'user', 'content' = array array 'type' = 'text', 'text' = $prompt , array 'type' = 'image url', 'image url' = array 'url' = $image url , , , , , 'max tokens' = 60, ; $response = wp remote post $endpoint, array 'timeout' = 30, 'headers' = array 'Authorization' = 'Bearer ' . $settings 'api key' , 'Content-Type' = 'application/json', , 'body' = wp json encode $body , ; // ... error handling, then trim/sanitize the returned text } Two prompt details earned their place after actually looking at model output on real sites: mb substr $alt, 0, 160 — models don't reliably respect "maximum 125 characters" as an instruction, so the code truncates again server-side rather than trusting the model's arithmetic.The query that drives the bulk-fix screen is a plain WP Query against attachments missing the meta key entirely — not empty string, missing , which is a different meta query compare NOT EXISTS vs = : php function atbyok attachments missing alt $limit = 50 { $q = new WP Query array 'post type' = 'attachment', 'post status' = 'inherit', 'post mime type' = 'image', 'posts per page' = $limit, 'meta query' = array array 'key' = ' wp attachment image alt', 'compare' = 'NOT EXISTS', , , ; return $q- posts; } post status = inherit is the detail that's easy to get wrong: attachments don't use the normal publish / draft statuses, they inherit their parent post's status or inherit on their own when unattached . Query for publish here instead and you silently miss every unattached media-library image — which, in practice on most sites, is most of them. The admin UI processes images one AJAX call per image instead of batching them server-side into one big request. That's not an accident — a bulk endpoint that loops over 300 images inside a single PHP request runs straight into max execution time on any shared host, and a failure partway through gives you no idea which images actually got done. One request per image, driven by JS, means progress is visible and a timeout on image 214 doesn't lose the 213 that already succeeded. WordPress.org doesn't allow selling anything inside a plugin listed there, so the free version ships with a genuine cap — 30 generations/month, tracked with a month-keyed option that resets itself: php function atbyok usage get { $data = get option 'atbyok usage', array ; $ym = gmdate 'Y-m' ; if is array $data || $data 'ym' ?? '' == $ym { return array 'ym' = $ym, 'count' = 0 ; } return $data; } Using the current year-month as the array key instead of a scheduled cron job to reset the counter means there's no maintenance task that can silently stop firing — the counter just naturally starts over the first time atbyok usage get runs in a new month. meta query on attachments: always double check NOT EXISTS vs = '' vs plain absence — WordPress doesn't guarantee the meta row exists at all for older uploads. post status is not what you'd guess; query inherit , not publish , if you want max execution time will find you eventually. I'm the author of Alt Text BYOK, a WordPress plugin that does exactly what's described above — bring your own API key, images never touch a third-party server of mine, free tier included.