Founder · Johannesburg · building for South Africa & Uganda

Language, networks,
and the machines
in between.

I run an early-stage AI company building applied ML for markets where the interface is still a USSD menu, the training data is code-switched, and the customer is a mobile operator. Sentiment and speech models tuned for African languages, natural-language systems sitting on live subscriber data, and a three-node Raspberry Pi cluster on my desk where all of it gets proven before it goes anywhere near production.

Most of what I ship is shaped by a constraint someone else would call a blocker — no GPU budget, no approved LLM vendor, no permission for the data to leave the building. Those are the interesting problems.

  • NLP · ASR · retrieval
  • Telco data & access
  • k3s · edge · MLOps
  • Mac mini + 3 × Pi 5
  • Zindi competitor

Track 01 — the models

Language & speech

African text and audio break most off-the-shelf models. Three projects on what it actually takes to make them work.

Live

Proof-of-capability
asset

South African sentiment pipelineSentiment classification for code-switched SA social text

A sentiment model tuned for how South Africans actually write online — English braided with isiZulu, Afrikaans and Sesotho inside a single post, plus local slang that generic English classifiers read as noise. Built on Cardiff NLP's Twitter-RoBERTa-base over a corpus of roughly 29,000 South African social posts.

The interesting problem was never the architecture. It was labelling: a large share of posts are genuinely ambiguous, and annotator disagreement on those cases silently caps how good any model can get. I used a hybrid strategy — clear cases labelled directly, ambiguous ones routed through LLaMA-assisted annotation before human adjudication.

What I learned

  • Off-the-shelf English sentiment models degrade sharply on code-switched text — the failure is quiet, not loud, and only shows up in per-class recall.
  • Cohen's kappa is the honest metric here. Accuracy flatters a model on an imbalanced corpus; kappa measures agreement beyond chance, which is the thing you actually care about with contested labels.
  • The annotation strategy moved the numbers more than any model swap did.
  • Twitter-RoBERTa-base
  • HuggingFace Transformers
  • PyTorch
  • LLaMA (annotation)
  • accuracy · F1 · macro-F1 · κ
CORPUS ~29,000 South African social posts code-switching · local slang · mixed register TRIAGE Confident cases labelled directly Ambiguous cases contested sentiment, sarcasm, mixed code LLaMA-assisted annotation proposes a label + rationale Human adjudication accept · override · discard TRAIN Fine-tune RoBERTa Cardiff NLP Twitter-base EVALUATE Benchmark suite accuracy · F1 · macro-F1 · Cohen's κ
Annotation is the load-bearing part of the pipeline, not the model.

Shipped

Checkpoint public
on HuggingFace

waxal-ext — multilingual African ASRWhisper large-v3 fine-tuned for Lingala and Swahili

A speech recognition fine-tuning pipeline targeting two languages Whisper handles unevenly out of the box. The best checkpoint is published as tobopat/whisper-large-v3-waxal-ext.

Most of the real effort went somewhere unglamorous: a long chain of environment and API compatibility failures across Colab and Jupyter, where library versions, the Transformers trainer API and the audio stack kept disagreeing with each other.

What I learned

  • On a large-v3 fine-tune, the environment is the experiment. Pinning versions early would have bought back most of the time I lost.
  • Low-resource ASR rewards data curation and audio preprocessing far more than extra training steps.
  • Whisper large-v3
  • HuggingFace Transformers
  • PEFT
  • Colab / Jupyter
  • HF Hub

Competition

Zindi hackathon
~0.604 best score

Multilingual health Q&ALuganda · Kiswahili · Akan/Twi · Amharic · English variants

A question-answering system for health queries across five low-resource African language subsets, built over four experimental phases: BM25+E5 hybrid retrieval, pure dense retrieval, QLoRA fine-tuning of Aya-Expanse-8B, and finally RAG-trained fine-tuning.

The best result came from refusing to pick one approach. Per-subset hybrid routing — choosing between the fine-tuned model and pure retrieval based on which language subset the question came from — outscored every uniform strategy at roughly 0.604 on the leaderboard.

What I learned

  • Pure E5 retrieval beat the BM25+E5 hybrid. Lexical matching helps less than expected when the query and corpus are in a morphologically rich language.
  • ROUGE's default tokenizer breaks on Ge'ez script, quietly deflating every Amharic score. A metric bug that looks exactly like a model problem.
  • RAG-trained fine-tuning — training the model on retrieved context, not just on answers — outperformed bare fine-tuning under ROUGE-heavy scoring.
  • Routing by subset is a legitimate architecture, not a hack, when the subsets have genuinely different data profiles.
  • Aya-Expanse-8B
  • QLoRA / PEFT
  • E5 embeddings
  • BM25
  • RAG
  • ROUGE

Track 02 — the products

Networks & access

Systems built on live operator data, and one built for the handset at the other end of it.

In build

Core loop working
on a live MoMo SIM

Voice-to-USSD for mobile moneySpeaking your way through MTN MoMo and Airtel Money in Uganda

Mobile money in Uganda runs on USSD — a menu tree you navigate by reading a screen and typing digits under a timeout. If you can't see the screen, you can't send money independently. This is a voice front end for that flow, built for visually impaired users.

It runs entirely on-device as an Android app using TelephonyManager.sendUssdRequest(), with a session manager and state machine driving the callback loop. The regulatory position matters as much as the code: framed as an accessibility layer rather than a payment intermediary, it stays clear of PSP licensing requirements.

What the field testing turned up

  • Menu shortcuts jump the tree — *165*8*1# lands straight on the PIN prompt for balance, *165*1*1# straight on "enter number you are sending to".
  • Fully pre-composed strings are rejected. You can compress the menu path, but MoMo insists on interactive entry for recipient, amount and reason — which is exactly why a session state machine is unavoidable.
  • The reason field is mandatory and needs at least four characters. Small detail, breaks the whole flow if you miss it.
  • MoMo normalises locally-entered numbers to full international format on the confirmation screen — so the confirmation you read back to the user won't match what they said.
  • The PIN is handed to MTN's own system dialog. Never voiced, never captured, never stored. The one place where the right answer was to build less.
  • Android / Kotlin
  • TelephonyManager USSD API
  • ADB · fastboot
  • Speech-to-text + TTS
  • State machine session manager
  • WhatsApp receipts (next)
USER Spoken instruction "send twenty thousand to Grace" ON DEVICE Intent + slot extraction recipient · amount · reason → validated before dialling Session manager · state machine TelephonyManager.sendUssdRequest() → callback loop, timeout handling NETWORK MoMo USSD session — *165*1*1# 1. recipient number 2. amount 3. reason (min 4 chars) 4. confirmation screen — recipient, amount, fee numbers returned in full international format HANDOFF MTN system PIN dialog app never sees the credential Spoken confirmation + WhatsApp receipt (planned)
The PIN handoff is the design decision the rest of the architecture bends around.

In design

Live operator data,
not synthetic

VVIP experience query systemPlain-language questions over subscriber experience data

An internal tool letting operator staff ask things like "when did this subscriber last have a dropped call, and why?" against a tracked group of high-value customers — dropped calls, site performance, and the root cause behind both.

The constraints are the design. The secondary store is Oracle, but not every root-cause signal is fed into it, so answering "why" means tool calls out to systems that hold the rest. And the target operator has no GPU infrastructure and no approved LLM vendor yet, which rules out anything that assumes a local model and a permissive data path.

Where it sits

  • Natural-language layer over a schema that was never designed to be asked questions in English.
  • Tool calling as the bridge between the warehouse and the signals that never reached it.
  • Vendor and governance approval is a real dependency, planned for rather than assumed away.
  • Runs on Sisyphus — the local agent runtime below, which is what makes "no approved LLM vendor" a solvable constraint instead of a blocker.
  • Oracle
  • Tool calling / function calling
  • Text-to-query
  • Telco experience KPIs
  • Sisyphus runtime

Pivoted

MTN · Airtel
SA & Uganda

Outage social listeningDetecting network outages before the ticket does

The goal: a near-real-time outage signal for the operators in my markets. The first attempt pulled from Downdetector and hit persistent rate limiting. Rotating through residential IPs on a bank of Raspberry Pis hit exactly the same wall — which was the useful result.

So the project turned around: rather than scraping an aggregator, extract the outage signal directly from the social posts the aggregator is reading anyway. That also feeds straight back into the sentiment work.

What I learned

  • When the aggregator is the bottleneck, go to its source. Two failed evasion attempts were cheaper than one wrong assumption about the data supply.
  • Rate limiting on a commercial data source is a product decision, not a technical obstacle to route around.
  • Python scrapers
  • Playwright
  • Raspberry Pi fleet
  • Social signal extraction

In progress

Enterprise AI
business case

AI value realizationProving return, not just capability

A framework layer over the rest of the portfolio: value measurement that carries both quantitative and qualitative evidence, TCO with break-even and sensitivity analysis, per-use-case KPIs surfaced on live dashboards, investment proposals with projected ROI, portfolio-level resource allocation, and detection of underperforming use cases early enough to remediate them.

It exists because the hardest part of enterprise AI is rarely the model. It's demonstrating, in the finance team's language, that the thing paid for itself.

  • TCO · break-even · sensitivity
  • KPI design
  • Real-time dashboards
  • Portfolio allocation
  • Stakeholder reporting

Track 03 — the substrate

Infrastructure

A three-node Raspberry Pi cluster standing in for a telco edge site, and the agents that run on it — self-healing, offline, and self-directed.

Running

3 × Pi 5 · k3s
Grafana live

AI Grid — telco edge compute simulationProtect critical workloads at peak, reclaim the capacity for AI off-peak

A three-node k3s cluster on Raspberry Pi 5s, roled as control plane, edge-critical and edge-ai, simulating how an operator might protect network functions during busy hours and hand the spare capacity to AI inference when the traffic drops.

It's built entirely from native Kubernetes primitives rather than a bespoke scheduler — node labels and affinity, three PriorityClasses (telco-critical, telco-standard, ai-burst), ResourceQuotas and LimitRanges per namespace, taints on the control plane, KEDA scaling AI jobs on a SAST cron schedule, and a CronJob orchestrator that flips the cluster between modes. A small Flask control UI on a MetalLB address does it manually when I want to force a switch.

What I learned

  • PriorityClasses are the whole design. The simulation is only meaningful because eviction order under real memory pressure decides who survives — everything else is bookkeeping.
  • Grafana annotation overlays on mode switches turned an abstract policy into something you can see happen.
  • On 8GB nodes, the un-containerised processes are what bite you. Two systemd services were quietly holding over a gigabyte on the control plane.
  • k3s v1.34
  • containerd
  • KEDA
  • Prometheus · Grafana · Loki
  • MetalLB · Traefik · Longhorn
  • Ollama
  • Flask
  • Descheduler
CONTROL PLANE mypi — k3s control plane (tainted) SQLite datastore · no workloads scheduled WORKER NODES mypi2 — edge-critical ns: telco-core · telco-edge PriorityClass: telco-critical / standard simulated RAN + MEC workloads ResourceQuota · LimitRange · affinity mypi3 — edge-ai ns: ai-burst PriorityClass: ai-burst (evicted first) Ollama inference · KEDA cron scaling opportunistic — takes what's spare ORCHESTRATION Grid orchestrator — peak / off-peak CronJob promotes and demotes workloads on a SAST schedule · manual override via Grid Control UI OBSERVABILITY Prometheus metrics · node-exporter Grafana mode-switch annotations Grid Control UI Flask · MetalLB address Loki log aggregation · Longhorn persistent volumes · Traefik ingress · Descheduler rebalancing
Native primitives only — the scheduler already knows how to do this if you configure it honestly.

Next

Builds on the
same cluster

Kubernetes AI Ops agentGraph-backed observability and self-healing

An agent platform layered on the Grid cluster: an ingestion pipeline pulling from the k8s API, Prometheus, runbooks and git events, embedding into a vector store and writing relationships into a graph, with an orchestrator routing questions to sub-agents — a self-healing ReAct loop watching for CrashLoopBackOff and OOMKilled, a RAG assistant doing hybrid retrieval, a graph analyst running Cypher over cluster topology, and an action agent with kubectl access.

Decisions worth defending

  • FalkorDB over Neo4j. Redis-compatible, ARM-friendly and materially lighter on memory — which is the binding constraint on an 8GB Pi.
  • Flat vector search can't answer "which services have been failing together?" Graph traversal can. That's the reason the graph store exists at all.
  • Qdrant for vectors — good ARM build, proven on Pi hardware.
  • FalkorDB
  • Qdrant
  • Ollama (phi3 · tinyllama)
  • nomic-embed-text
  • ReAct / LangGraph
  • kubernetes Python client
  • Cypher

Runtime

The engine under
the VVIP system

Sisyphus — offline agent runtimeA provider-agnostic tool-calling harness running fully local on Qwen3

Sisyphus is what the VVIP experience system actually runs on. A tool registry with JSON Schema validation and sandboxed execution, a swappable provider interface, and an agentic loop with a hard iteration cap — the unglamorous plumbing that decides whether an agent is dependable or merely impressive in a demo.

It started against a hosted API and then got pointed at a locally-served model: plain HTTP against any OpenAI-compatible endpoint, no vendor SDK, so vLLM, Ollama, llama.cpp and SGLang are one config line apart. That was a requirement, not a preference. The target operator has no approved LLM vendor and the data is live subscriber traffic — a hosted endpoint isn't a deployment path, it's a procurement cycle.

The name is the joke and the design brief. The loop pushes, the loop fails, the loop pushes again — so the interesting engineering is all in what happens when the model gets it wrong.

What I learned

  • Recovery beats benchmarks. A model that emits a plausible-but-wrong query and can't correct itself after seeing the error is useless in a loop, whatever its leaderboard position. That property is what I select on now.
  • Narrow the tools. A general "run SQL" tool is both unreliable and a wide attack surface against live operator data. Parameterised, purpose-built tools fixed reliability and security in the same move.
  • Three format differences will bite when you swap providers: the nested tool schema shape, the per-message tool-result role, and arguments arriving as strings that need defensive JSON parsing.
  • vLLM needs --enable-auto-tool-choice and a model-family-specific tool-call parser, or tool_calls comes back empty while the model is emitting perfectly good tokens.
  • When native tool calling is shaky, drop the tools parameter entirely and prompt for JSON with grammar-constrained decoding. Same provider interface, third implementation.
  • Build the eval set first — around thirty real questions with known-correct tool sequences. It's the only honest way to know whether a local model is good enough before it touches production data.
  • Qwen3
  • vLLM · Ollama · llama.cpp · SGLang
  • OpenAI-compatible HTTP
  • JSON Schema
  • Grammar-constrained decoding
  • Offline-first
TOOLS Tool registry JSON Schema validation sandboxed execution · narrow, parameterised LOOP Agentic loop call → execute → feed result back hard iteration cap · error recovery PROVIDER INTERFACE Swappable provider — one protocol, three implementations hosted API · OpenAI-compatible HTTP · grammar-constrained JSON fallback SERVED LOCALLY vLLM auto tool choice + parser Ollama handles parsing internally llama.cpp · SGLang GBNF grammars Qwen3 — no data leaves the operator's network
Nothing about the loop assumes a vendor. That's the only reason it can run where the data lives.

Research only

One agent
per Pi

PiLab — research scout & plannerWhat's new in telco, fintech and data science — and what will actually run on a Pi

A three-agent system, one per node, that reads the field so I don't have to. The Scout monitors arXiv, Hacker News, X, Reddit, GitHub trending and releases, RSS tech blogs and a set of YouTube channels. The Evaluator scores what it finds against the thing that actually matters here — will this run on 8GB ARM nodes, and is it worth the effort. The Planner turns whatever survives into a build plan and tracks it.

Deliberately research-only. It scouts and evaluates; I decide what gets built. An autonomous agent that starts deploying things to my own cluster is a much harder problem than the one I set out to solve, and a worse one.

What I learned

  • The feasibility filter is the product. Finding interesting papers is trivial and produces noise; judging what fits constrained ARM hardware is the part with no substitute.
  • YouTube is an underrated signal source — practical implementation detail lands there weeks before it reaches a paper or a blog post.
  • The benchmark harness earns its keep: comparing llama.cpp RPC against exo for distributed inference across the three Pis, at several context sizes, turns "which is faster" into a table instead of an opinion.
  • FastAPI
  • SQLite
  • Ollama
  • asyncssh
  • llama.cpp RPC · exo
  • Tailscale
  • Playwright
SOURCES arXiv · Hacker News · X · Reddit · GitHub trending & releases · RSS blogs · YouTube filtered to telco · fintech · data science AGENTS — ONE PER NODE Scout polls sources dedupes, summarises writes to queue Evaluator runs on Pi hardware? memory · ARM build scores & discards Planner build plan per candidate milestone detection progress monitor SHARED SQLite store + work queue local Ollama for summarise & score Cluster benchmark harness llama.cpp RPC vs exo, by context size SURFACE FastAPI + dashboard pipeline view · node status · benchmark history · reachable anywhere over Tailscale Human decides what gets built — the system never deploys on its own.
Read the field, filter for what fits the hardware, hand a plan to a human.

Track 04 — also in flight

  • fploverdrive.com

    FPL Overdrive

    Fantasy Premier League analytics — a planner screen with player pool stats by position, projected xP over a gameweek range, price and elite ownership. Advanced analytics and autonomous team management, chasing green arrows.

  • feeds FPL Overdrive

    Match odds model

    Deriving clean-sheet and expected-goals probabilities from bookmaker lines, cross-referenced against independent per-fixture projections. Becomes a data feed rather than a standalone product.

  • agenticfinance.co.za

    Agentic finance

    An agent that writes about advances in agentic finance — currently digging into x402 and machine-to-machine payment rails.

  • early

    Forex trading agent

    Systematic rather than discretionary from the start: the goal is an algorithm on a MetaTrader-style platform, not a trading habit.