By Thales & Claude -- CEO & AI CTO, ZeroSuite, Inc.
A teacher in Abidjan creates a class of 35 students. She generates a join code, writes it on the blackboard, and tells her students to enter it in the Deblo app. Twenty minutes later, 35 students are connected to her organization, drawing from a shared credit pool she manages. She can see who has completed their exercises, who asked for help with fractions, and who has not logged in yet.
A parent in Dakar creates a family organization with three children. She tops up the shared credit balance once a month, and her children use Deblo for homework help, quiz practice, and exam preparation. She sees each child's activity from her parent dashboard, and she controls how many credits each child can consume per day.
A managing partner at an accounting firm in Douala creates a company organization. Twelve associates join using the company code. They share access to the firm's SYSCOHADA templates, collaborate on audit projects, and generate documents that are automatically associated with the organization. The partner manages the credit pool and reviews usage analytics.
Three very different use cases. One organization system.
---
Why Multi-Tenant Matters for Africa
The decision to build a multi-tenant organization system was not a premature abstraction. It was driven by how education and professional services actually work in francophone West and Central Africa.
In most African school systems, technology adoption is a top-down decision. A school director or a teacher decides to use a platform, and students follow. Individual student adoption is rare -- most K12 students do not have personal smartphones, personal email addresses, or personal payment methods. They access technology through institutions. If Deblo cannot model a school as an entity with its own credit pool and member management, it cannot serve schools at all.
Family structures in West Africa are often extended. A parent or guardian might be responsible for 3-7 children's education. Managing separate accounts and separate credit balances for each child is impractical. A family organization with one credit pool and one billing relationship is the natural model.
Professional firms operate similarly. An accounting firm in Cote d'Ivoire might have 5-20 professionals who all need access to SYSCOHADA resources, document generation, and AI advisory. Individual subscriptions are expensive and unmanageable. A company organization with centralized billing and usage monitoring is what the firm's managing partner expects.
The organization system is not a nice-to-have feature. It is a prerequisite for institutional adoption across all three market segments.
---
The Data Model
The organization system consists of three core models: Organization, OrgMembership, and the join/access code infrastructure.
class OrgType(str, enum.Enum):
FAMILY = "family"
SCHOOL = "school"
COMPANY = "company"class Organization(Base): __tablename__ = "organizations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) name = Column(String(200), nullable=False) slug = Column(String(200), unique=True, nullable=False, index=True) org_type = Column(SAEnum(OrgType, name="org_type_enum"), nullable=False)
join_code = Column(String(20), unique=True, nullable=False) credit_balance = Column(Integer, nullable=False, default=0)
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# Optional metadata description = Column(Text, nullable=True) logo_url = Column(String(500), nullable=True) max_members = Column(Integer, nullable=True) # null = unlimited
created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships owner = relationship("User", back_populates="owned_organizations") memberships = relationship("OrgMembership", back_populates="organization", cascade="all, delete-orphan") ```
The Organization model is intentionally lean. name, slug, org_type, join_code, credit_balance, owner_id -- these six fields capture the essential identity and economics of any organization. The org_type enum determines the UI treatment (school shows class management features, family shows parental controls, company shows project workspaces) but the underlying data model is identical.
credit_balance is stored directly on the organization, not in a separate billing table. This is a deliberate simplification. An organization has one credit pool. Members draw from it. The owner tops it up. There is no per-member credit allocation at the database level -- that is handled by application-layer daily limits.
The membership model tracks who belongs to which organization and their role:
class OrgRole(str, enum.Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
VIEWER = "viewer"class OrgMembership(Base): __tablename__ = "org_memberships"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) org_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) role = Column(SAEnum(OrgRole, name="org_role_enum"), nullable=False, default=OrgRole.MEMBER)
daily_credit_limit = Column(Integer, nullable=True) # null = no limit join_date = Column(DateTime(timezone=True), server_default=func.now()) is_active = Column(Boolean, default=True)
# Unique constraint: one membership per user per org __table_args__ = ( UniqueConstraint("org_id", "user_id", name="uq_org_user"), )
organization = relationship("Organization", back_populates="memberships") user = relationship("User", back_populates="org_memberships") ```
Four roles cover the permission model. owner can do everything: manage members, manage billing, delete the organization. admin can manage members and view analytics but cannot delete the organization or manage billing. member can use the platform and draw from the shared credit pool. viewer can see shared projects and conversations but cannot create new ones or consume credits.
For schools, the mapping is natural: the teacher is owner or admin, students are member. For families: the parent is owner, children are member. For companies: the managing partner is owner, senior associates are admin, junior associates are member, interns are viewer.
---
Join Code Generation
When an organization is created, a unique join code is auto-generated. The code serves as the primary onboarding mechanism -- the owner shares the code, and new members enter it to join.
import secrets
import stringdef generate_join_code(org_type: OrgType) -> str: """Generate a unique, human-readable join code.
Format: {PREFIX}-{4 chars}-{4 chars} Prefix indicates org type for quick visual identification.
Examples: SCH-AB3K-7YPN (school) FAM-RT5W-2MJX (family) CMP-KL9D-4HVQ (company) """ prefixes = { OrgType.FAMILY: "FAM", OrgType.SCHOOL: "SCH", OrgType.COMPANY: "CMP", } prefix = prefixes[org_type]
# Use uppercase letters + digits, excluding ambiguous characters (0/O, 1/I/L) alphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789" segment1 = "".join(secrets.choice(alphabet) for _ in range(4)) segment2 = "".join(secrets.choice(alphabet) for _ in range(4))
return f"{prefix}-{segment1}-{segment2}" ```
Several design decisions in this function reflect the African deployment context.
The code is 12 characters including hyphens. It needs to be short enough that a teacher can write it on a blackboard and 35 students can type it on phone keyboards without errors. Longer codes increase the error rate dramatically, especially on small screens.
Ambiguous characters are excluded. 0 and O look identical on many African students' low-resolution phone screens. 1, I, and L are similarly confusing. The reduced alphabet slightly increases the minimum code length needed for collision resistance, but the tradeoff is worthwhile. A code that students can enter correctly on the first try is more valuable than a shorter code that 20% of them mistype.
The prefix (SCH, FAM, CMP) serves two purposes: it prevents cross-type collisions (a school code cannot accidentally match a family code), and it gives the owner visual confirmation that they are sharing the right type of code.
---
Access Code Login: For Students Without Phones
This is where the organization system diverges most sharply from standard SaaS patterns. In a typical multi-tenant application, every user has an email address or phone number for authentication. In African K12 education, a significant number of students -- especially those in primary school (CP through CM2, ages 6-12) -- do not have personal phones.
These students access Deblo through shared devices: a school computer lab, a parent's phone, a sibling's tablet. They cannot receive SMS OTPs. They do not have email addresses. They need a different authentication path.
The solution is the access code: a 12-character PIN in the format CI-ZB8T-XXXX that serves as both identifier and password. The organization owner (teacher or parent) generates access codes for members who cannot authenticate via phone.
The access code login endpoint bypasses the phone/OTP flow entirely:
@router.post("/auth/access-code")
async def login_with_access_code(
request: AccessCodeLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Authenticate a user via organization access code.Access codes are generated by org owners for members without phones. The code is both the identifier and the credential. """ # Find the user by access code result = await db.execute( select(User) .where(User.access_code == request.access_code) .where(User.is_active == True) ) user = result.scalar_one_or_none()
if not user: raise HTTPException(status_code=401, detail="Code d'acces invalide")
# Verify the user belongs to an active organization membership = await db.execute( select(OrgMembership) .where(OrgMembership.user_id == user.id) .where(OrgMembership.is_active == True) ) if not membership.scalar_one_or_none(): raise HTTPException( status_code=401, detail="Ce code n'est plus actif. Contactez votre enseignant." )
# Issue JWT (same token format as phone auth) token = create_access_token( user_id=str(user.id), auth_method="access_code", )
return {"token": token, "user": serialize_user(user)} ```
The access code is stored hashed in the database, just like a password. But unlike passwords, access codes are system-generated, not user-chosen. This eliminates the weak-password problem entirely. A 12-character code from a 30-character alphabet provides approximately 54 bits of entropy -- sufficient for an authentication credential that is also rate-limited to 5 attempts per minute.
The auth_method field in the JWT token distinguishes access code sessions from phone-authenticated sessions. This enables differentiated rate limiting (access code users get slightly lower limits to prevent abuse if a code leaks) and analytics (we can track what percentage of users authenticate via each method).
---
Shared Credit Pool
The credit pool model is the economic engine of the organization system. Instead of each member maintaining an individual credit balance, the organization has a single pool that all members draw from.
The credit deduction logic checks both sources -- personal balance and organization pool -- with the organization pool as the primary source for org members:
async def deduct_credits(
user_id: UUID,
amount: int,
db: AsyncSession,
description: str = "",
) -> bool:
"""Deduct credits from the appropriate source.Priority: 1. If user belongs to an org with sufficient balance, deduct from org pool. 2. If org pool is insufficient, fall back to personal balance. 3. If both are insufficient, return False (insufficient credits).
Enforces daily_credit_limit for org members. """ # Check for active org membership membership = await get_active_membership(user_id, db)
if membership: # Check daily limit if membership.daily_credit_limit is not None: today_usage = await get_daily_usage(user_id, membership.org_id, db) if today_usage + amount > membership.daily_credit_limit: return False # Daily limit exceeded
org = membership.organization
if org.credit_balance >= amount: # Deduct from org pool org.credit_balance -= amount await record_credit_transaction( user_id=user_id, org_id=org.id, amount=-amount, source="org_pool", description=description, db=db, ) await db.flush() return True
# Fall back to personal balance user = await db.get(User, user_id) if user and user.credit_balance >= amount: user.credit_balance -= amount await record_credit_transaction( user_id=user_id, org_id=None, amount=-amount, source="personal", description=description, db=db, ) await db.flush() return True
return False ```
The priority order -- organization pool first, personal balance second -- is intentional. When a student uses Deblo through their school, the school pays. The student's personal balance (if they have one) is reserved for personal use outside school hours. This mirrors how school supplies work in the physical world: the school provides textbooks during class, and the student buys their own for home study.
The daily_credit_limit on OrgMembership is a critical parental and institutional control. A parent can set a daily limit of 50 credits per child, preventing a curious 10-year-old from burning through the family's entire monthly credit allocation in one evening of enthusiastic conversation with the AI. A teacher can set per-student limits to ensure equitable access across the class.
Every credit transaction is recorded with source (org_pool or personal) and org_id. This enables the owner's billing dashboard to show exactly who consumed how many credits, when, and for what purpose. Transparency in credit consumption builds trust -- a school director needs to justify the technology budget to the parent association, and detailed usage reports make that conversation straightforward.
---
Projects and Task Management
Organizations are not just billing units. They are collaboration spaces. Within an organization, members can create projects -- workspaces where conversations, files, and tasks are grouped around a shared objective.
A school might have a project for each subject: "Mathematiques CM2," "Sciences de la Vie et de la Terre 3eme," "Philosophie Terminale." An accounting firm might have projects for each client engagement: "Audit SARL Kouame 2025," "Rapport Fiscal Groupe Bamba."
Task management within organizations enables lightweight project coordination. The create_task tool -- callable by the AI during conversation -- creates tasks with assignees, priorities, due dates, and recurrence patterns:
- A teacher says "Create a weekly homework task for all students: complete 10 multiplication exercises every Monday." The AI creates a recurring task assigned to all members with
recurrence: weeklyanddue_day: monday. - A managing partner says "Remind Kouame to finish the cash flow analysis by Friday, high priority." The AI creates a one-time task assigned to a specific member.
Tasks have comments (TaskComment) for threaded discussion and notifications (TaskNotification) that alert assignees via the in-app notification system. This is not a full-featured project management tool -- it is a lightweight coordination layer that lives inside the conversational interface. Users do not switch to a separate task management app. They tell the AI what needs to happen, and the AI creates the appropriate task structure.
---
API Endpoints
The organization API follows RESTful conventions with role-based access control:
POST /api/org # Create organization (any authenticated user)
GET /api/org/{id} # Get org details (members only)
PATCH /api/org/{id} # Update org settings (owner/admin)
DELETE /api/org/{id} # Delete org (owner only)POST /api/org/{id}/invite # Generate invite link (owner/admin) POST /api/org/{id}/join # Join via code (any authenticated user) GET /api/org/{id}/members # List members (owner/admin) PATCH /api/org/{id}/members/{user_id} # Update member role/limits (owner/admin) DELETE /api/org/{id}/members/{user_id} # Remove member (owner/admin)
GET /api/org/{id}/credit-pool # Credit pool balance + transactions (owner/admin) POST /api/org/{id}/credit-pool/topup # Top up credits (owner)
GET /api/org/{id}/projects # List projects (members) POST /api/org/{id}/projects # Create project (owner/admin/member)
GET /api/parent/children # Parent-specific: children's activity summary ```
The /api/parent/children endpoint deserves mention. It is specific to family organizations and returns a summary of each child's activity: last login time, credit consumption this week, exercises completed, quiz scores, and active conversations. This is the parent dashboard -- a quick overview that answers the question "Are my children actually using this for schoolwork?"
Role checks are implemented as FastAPI dependencies. Every organization endpoint first verifies that the requesting user is a member of the organization and has the required role. A member cannot access the credit pool details. A viewer cannot create projects. An admin cannot delete the organization. These checks are consistent and centralized, not scattered across individual endpoint implementations.
---
The Join Flow in Practice
The complete join flow, from organization creation to a student's first message, illustrates how the pieces fit together:
1. The teacher creates an account on Deblo via phone + WhatsApp OTP.
2. She navigates to "Create Organization" and selects type "School."
3. The system generates a join code: SCH-KB7T-4MPN.
4. She writes the code on the blackboard.
5. Students with phones: open Deblo, tap "Join Organization," enter the code. They are added as member with a daily credit limit of 100.
6. Students without phones: the teacher generates access codes for them from her dashboard. Each student receives a printed card with their code: CI-ZB8T-XXXX. They enter this code on a shared device to log in.
7. The teacher tops up the organization's credit pool using Mobile Money (Orange Money, Wave, MTN Money -- see Article 6).
8. Students start chatting with Deblo. Credits are deducted from the school's pool.
9. The teacher checks her dashboard: 28 of 35 students have logged in, 142 exercises completed today, average quiz score 73%.
This flow takes about 30 minutes from creation to active classroom use. No email verification, no app store accounts, no payment card details. A phone number, a join code, and Mobile Money. These are the primitives that work in West Africa.
---
Scale Considerations
The organization system is designed for schools with up to 500 students per organization and companies with up to 100 members. Beyond these sizes, the credit pool management and member listing become unwieldy in the current UI. Larger institutions (a university with 5,000 students) would need a hierarchical structure -- departments within the organization, each with its own sub-pool -- which is on the roadmap but not yet implemented.
The database queries are optimized for the common access patterns. Member listing is paginated. Credit balance checks use a single indexed column lookup. Daily usage calculation is a sum over today's credit transactions, indexed by (user_id, org_id, created_at). The join code lookup uses a unique index. None of these queries will become a bottleneck at the scale we are targeting for the first year.
The access code system has a practical limit driven by human factors, not technical ones. A teacher managing 35 printed access code cards can handle it. A school director managing 500 access codes needs a bulk management interface -- export to CSV, bulk generation, bulk deactivation. We built this into the admin dashboard for school organizations.
---
What We Learned
Three lessons from building and deploying the organization system:
First, the credit pool model is more natural than individual billing for institutional users. Every school director and firm manager we spoke to immediately understood "one account, one balance, shared by everyone." Individual per-student billing confused them and created administrative overhead they were not willing to accept.
Second, access codes for phoneless students unlocked an entire user segment we would have lost otherwise. Approximately 30% of K12 users on the platform authenticate via access code. These are primary school students who would simply not be able to use Deblo without this feature. The implementation cost was modest -- a single additional auth endpoint -- but the impact on addressable market was significant.
Third, the daily_credit_limit on memberships was added after a parent reported that her child spent 400 credits in one evening (the equivalent of roughly two hours of continuous AI conversation). The feature was not in the original design. It was an immediate response to a real user's legitimate concern about consumption control. It took four hours to implement and deploy, and it resolved a class of complaints that would have blocked family adoption.
---
This is Part 14 of a 20-part series on building Deblo.ai.
1. AI Tutoring for 250 Million African Students 2. 100 Sessions Later: The Architecture of an AI Education Platform 3. The Agentic Loop: 24 AI Tools in a Single Chat 4. System Prompts That Teach: Anti-Cheating, Socratic Method, and Grade-Level Adaptation 5. WhatsApp OTP and the African Authentication Problem 6. Credits, FCFA, and 6 African Payment Gateways 7. SSE Streaming: Real-Time AI Responses in SvelteKit 8. Voice Calls With AI: Ultravox, LiveKit, and WebRTC 9. Building a React Native K12 App in 7 Days 10. 101 AI Advisors: Professional Intelligence for Africa 11. Background Jobs: When AI Takes 30 Minutes to Think 12. From Abidjan to 250 Million: The Deblo.ai Story 13. Generating PDFs, Spreadsheets, and Slide Decks From a Chat Message 14. Organizations: Families, Schools, and Companies on One Platform (you are here) 15. Interactive Quizzes With LaTeX: Testing Students Inside a Chat 16. RAG Pipeline: Document Search With pgvector and Semantic Chunking 17. Six Languages, One Platform: i18n for Africa 18. Tasks, Goals, and Recurring Reminders 19. AI Memory and Context Compression 20. Observability: Tracking Every LLM Call in Production