This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
If you sell on Instagram or WhatsApp in Nigeria, you've typed this caption before: "Available. DM to order." For the fifth product that day.
Oja ("market" in Yoruba) writes the listing for you. Upload one to three photos, add a hint if you like (what it is, its condition, the price you have in mind), pick a voice, and get back:
- a title
- an Instagram caption with hashtags
- a longer description
- a WhatsApp status, with an Open in WhatsApp button
- a rough Naira price guide (a guide, never a promise)
The voices are Clear English, Naija Pidgin and Yoruba-flavoured English. Every piece has a copy button, because the people who'd use this are on their phones, between customers.
Oja in numbers
- Live at oja-ng.tech, with the code on GitHub under the MIT licence.
- 3 real sellers tried it, and all 3 said they'd pay. Two named a figure: about ₦10,000 and ₦15,000 a month.
- About 150 to 215 s per listing running Gemma 3 locally on a CPU-only laptop, and about 7 to 34 s hosted with Gemma 4 (different models, details below).
- 115 tests, and 0 axe accessibility violations in every state I tested.
I built it for Nigerian sellers because most tools I see are made with another market in mind. I wanted a seller to open something that already feels like theirs: products they recognise, a market they know, and a voice that sounds like how they talk to customers. If English isn't where they're most at home, Pidgin might be, and I wanted them to feel heard, not translated.
"Yoruba-flavoured" means English with light Yoruba touches. It is not full Yoruba. Real Yoruba output is the next thing I want to build.
Who would pay? Anyone who sells online and posts new stock regularly. All three sellers who tried Oja said they would pay. Two gave amounts (about ₦10,000 and ₦15,000 a month), and the third said it depends on the value of what he sells (details in My First User).
The moment I knew the prompt was wrong
An early hosted Pidgin test was a striped tote bag. The description came back like this:
This tote bag get beautiful rust color with yellow stripes and teal polka dots. E simple but e get style well well. You fit carry am go market, work, or even outing. We dey deliver for Lagos, Abuja and Port Harcourt…
Good Pidgin. But the WhatsApp status from the same run came back in plain English, and the caption was only half in voice. Only the description was fully in character. I rewrote the prompt so every field follows the chosen style, with a worked example per style. Here is a Pidgin listing for a jar of cherry jam, from the saved example on the live site (written by gemma-4-26b-a4b-it on 16 October 2026, with the prompt as it was after that fix):
Caption: Oya see fine Cherry Jam! Blue Pearl brand, e sweet well well. DM to order.
Description: This na Blue Pearl Cherry Jam inside glass jar. E get white label with cherry picture and red patterned lid. DM to order, we dey deliver for Lagos, Abuja and Port Harcourt.
WhatsApp status: Blue Pearl Cherry Jam don land! Sweet jam inside glass jar. DM to order, we dey deliver nationwide.
Price guide: ₦2,500 to ₦4,500, "Price fit change based on jar size"
Demo
Live: https://oja-ng.tech (a paid Render instance, so it doesn't sleep)
The hosted demo uses a free-tier model endpoint, which sometimes has a bad moment. If you see "The AI service had a hiccup", press the button again, or press Try an example to see a saved result straight away. (The saved examples were written before my last prompt change.)
Code
SeyinAlao / oja
Oja turns one product photo into Instagram and WhatsApp listings for Nigerian sellers, in English, Naija Pidgin or Yoruba-flavoured English. Open-weight Gemma, OpenAI-compatible endpoint, runs hosted or local.
Oja
Listing writer for small Nigerian sellers on Instagram and WhatsApp. Upload 1 to 3 product photos, add optional hints, pick a voice (Clear English, Naija Pidgin or Yoruba-flavoured English) and get a title, caption with hashtags, description, WhatsApp status and a rough price guide, ready to copy.
- Live demo: https://oja-ng.tech
- Demo video: https://www.youtube.com/shorts/oaQ9pkC1Vhw
- Built for the Hacktoberfest 2026 Open-Source AI Challenge, Week 2 (Side Hustle).
Built with Next.js (App Router), TypeScript (strict), Tailwind CSS and Framer Motion. The AI is any open-weight vision model behind an OpenAI-compatible endpoint; the default is gemma3:4b on local Ollama.
Both screenshots are real generations from hosted gemma-4-26b-a4b-it through Google's OpenAI-compatible endpoint (the model badge in each shows it), not mock-ups.
Run locally
ollama pull gemma3:4b ollama serve # skip if Ollama is already running pnpm install pnpm dev # http://localhost:3000 Other scripts: pnpm test, pnpm lint, pnpm typecheck, pnpm build…
MIT licensed. The README covers running it locally with Ollama, pointing it at another endpoint, deploying to Render, model licences and known limitations.
How I Built It
Two ways to run the same app
| Setup | Model | Time per listing |
|---|---|---|
| Local, Ollama, CPU only | gemma3:4b | 148 to 157 s on the server; 170 to 215 s through the UI |
| Hosted, Google AI Studio free tier | gemma-4-26b-a4b-it | about 7 to 11 s in my benchmark runs; 20 to 34 s in runs through the UI |
These are different models, so please don't read this as a same-model speed test. It shows what each setup feels like to a seller. I haven't worked out why the UI numbers are higher than the server benchmark. Google's model picker describes Gemma 4 26B A4B as a mixture-of-experts model that activates about 4B parameters per inference.
App. Next.js (App Router), TypeScript in strict mode, Tailwind v4, Framer Motion and zod. One route, /api/generate, talks to any OpenAI-compatible chat/completions endpoint, and three environment variables choose the model:
# Hosted: Gemma 4 on Google AI Studio AI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/ AI_MODEL=gemma-4-26b-a4b-it AI_API_KEY=... # Local: Gemma 3 on Ollama, nothing leaves the machine AI_BASE_URL=http://localhost:11434/v1 AI_MODEL=gemma3:4b Prompting. The system prompt is written for Nigerian commerce: Naira formatting, "DM to order", delivery wording for Lagos, Abuja and Port Harcourt. Each style has its own voice rules and a worked example. The model first lists what it can see in the photos (colours, patterns, handles, straps), which pushes the copy toward concrete details. The reply must be strict JSON, validated with zod, with one repair call if it isn't.
Privacy. Photos are resized in the browser, sent once to the model and never stored. There's no database. The route has a rate limit and a request size cap.
Quality. 115 unit tests (vitest) and an axe-core check with 0 violations in every state I tested, including the saved-example mode. I have not done manual screen-reader testing yet.
How do I know the Pidgin isn't wrong?
One of my testers asked exactly this, and the honest answer is that Oja can't know. Here is what I do and don't have:
- What I have: a worked example per style in the prompt, an automated check that voice markers appear in all three text fields, and my own reading of sample outputs. I'm a Yoruba speaker, so I read the Yoruba-flavoured outputs myself.
- What that can't tell me: a marker check can see that "dey" appears. It can't tell whether a sentence sounds natural or fits the product. In some runs the Pidgin tote listings suggested "summer wear" and "beach", which the photo doesn't support, and they still passed the voice check. Pidgin and Yoruba also differ by region, age and context, and one reviewer is not a test.
- Design choice: Oja shows the draft for the seller to read before posting, because they're the best judge of their own voice. The results page now says so.
- Next: a "doesn't sound right" button for sellers, and a set of Pidgin and Yoruba listings reviewed by native speakers, which is the data you'd need to fine-tune an open-weight model.
Suggestions are a feature. Claims are not.
My first prompt told the model never to invent details, and it kept adding one short use suggestion anyway ("enjoy it with bread or pancakes" for jam). I liked that. It's what a good shop assistant says. So I changed my mind: the prompt now allows one short, generic use suggestion, and forbids stating facts the photos and hints don't support: material, size, brand, authenticity, condition, battery details, longevity and the like. That matters most for categories like perfume and used phones, where an invented "original" or "no scratches" could mislead a buyer.
I measured it with 10 live generations per style on two images (the jam jar photo, and a flat illustration of a tote bag), before and after the change:
| Old prompt | New prompt | |
|---|---|---|
| Runs with an unsupported claim | 2 of 60 | 0 of 60 |
| Runs with a sensible use suggestion | 32 of 60 | 57 of 60 |
| Valid JSON on the first try | 60 of 60 | 60 of 60 |
| Voice check passed | 59 of 60 | 59 of 60 |
The two old unsupported claims were a "sturdy" handle and "orange fabric" on the tote bag. My coding agent read all 120 outputs and counted them, rather than relying only on a regex. It's a small sample, and going from 2 to 0 is not a guarantee. That's why Oja now shows a line under the results: "Read it through and fix anything that isn't true for your product before you post."
What went wrong on the way
- Smaller images made it worse. To cut latency I tried 768 px photos instead of 1024 px. In my test, 0 of 9 responses were valid JSON on the first try, and 2 failed outright. I kept 1024 px.
- A latency setting that wasn't available. Google's endpoint rejected a reasoning-effort option for this model (HTTP 400 or 500).
- I leaked my own endpoint. The "model unreachable" error panel echoed the model server URL to the browser. The error-path tests I had my coding agent write caught it before any visitor did.
- A lost backslash. The agent's own edit made
Retry-After: 1wait 0 ms. A test caught that too. - My guard was too strict. The "no sellable product" check rejected a flat illustration of a tote bag as "not a real product", though the same image had produced a listing earlier. It works on real photos, but it can refuse mockups.
- Buttons didn't look clickable. Tailwind v4 stopped giving buttons a pointer cursor. I noticed when I hovered over mine and nothing happened.
Reliability, honestly
The free hosted endpoint sometimes returned HTTP 500, 502 and 503. In a batch of 9 calls with my early prompt, 2 failed. On the live site, some listings worked at once and some needed up to four tries. I added one automatic retry on 429, 5xx and network errors, plus an optional fallback model. The fallback never triggered in my testing, so I can't claim it helped. By design it only fires after the retry fails on a 429, 5xx or network error, not on timeouts or invalid JSON. I also added a Try an example button that shows a saved result instantly, labelled as saved and not live, so a bad moment on the free tier doesn't leave a visitor with nothing.
What it can't do yet
- Yoruba is "flavoured" English, not Yoruba.
- It may refuse illustrations and mockups.
- Local mode is far too slow on my CPU-only laptop to recommend today.
- In hosted mode, the photo goes to the model provider. I haven't verified what Google's free tier does with prompts, so check before using real customer photos.
- The price guide is a model's estimate, not market data.
Why Does Open Innovation Matter?
One app, two ways to run it. I moved Oja from local Ollama to Google's hosted Gemma by changing three environment variables. With a closed API, "where do my seller's photos go?" has one answer. With open weights it has two: a hosted endpoint when speed matters, or the seller's own machine when privacy does. Local mode was too slow on my laptop, but it works, and a machine with a GPU would change that. I haven't tested that.
No lock-in. If a provider changes its price or has a bad week, the plan is a config change, not a rewrite. I built the architecture for that but haven't proven it in a real outage.
A market a general model ignores. Today Pidgin and Yoruba come from prompting alone. Open weights are what make fine-tuning on reviewed Pidgin and Yoruba listings possible, so the voice is trained instead of prompted.
Not every "open" is the same. Google's Gemma 4 license page is the Apache License 2.0. The Gemma 3 model I use locally comes under Google's own Gemma Terms of Use, which include a prohibited-use policy. Both let me run the weights myself, but the terms differ, so the README links both. Oja doesn't redistribute any weights, and anyone building a business on either should read the terms first.
Where open did not win. Running it locally is too slow on my laptop for a real seller, and the free hosted tier is flaky. I also didn't benchmark a closed API, so I can't claim Oja is cheaper or better than one. The hosted tier cost me nothing during the challenge, but I haven't worked out what a listing would cost at paid rates, and that's the number I'd need before charging sellers.
My Agent Session
I built Oja with Claude Code, an AI coding agent. The sessions are saved with DevRelay:



![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)
