Custom App Developer Guide

Build and publish apps on the Chinuch platform — for teachers, students, and school admins.

How It Works

  1. Build your app as a folder of static files (HTML, CSS, JS)
  2. Upload the files through the submission form
  3. An AI security scan runs automatically on your code
  4. Our admin team reviews and approves your app
  5. Approved apps appear in the marketplace for schools to install
  6. When a user opens your app, they see it embedded in the Chinuch platform

Requirements

  • index.html at the root of what you upload — that’s the entry point
  • Allowed file types: .html .css .js .json .png .jpg .jpeg .gif .svg .woff .woff2 .ttf .ico .webp
  • Folders are preserved — drag the whole folder in (up to 8 levels deep)
  • Max 5 MB per file · max 50 files per app
  • Apps run in a sandboxed iframe — no access to parent cookies or storage
  • Content must be appropriate for a Jewish educational environment

Project Structure & Build

The platform serves your files directly to the browser through a sandboxed iframe. There is no Node server, no npm start, no build pipeline on our side. That means only files a browser can run on its own will work: .html,.css, .js, images, and fonts.

The golden rule: upload the build output, not your source code. For plain HTML projects there is no build step — just upload the project folder.

What to upload by framework

FrameworkBuild commandFolder to upload
Plain HTML / CSS / JS— (no build)your project folder
Next.js (static export)next build (with output: 'export')out/
Vite (React, Vue, Svelte, Solid)npm run builddist/
Create React Appnpm run buildbuild/
Astro (static)npm run builddist/
SvelteKit (static adapter)npm run buildbuild/

When you drag a folder into the uploader, the folder name itself is stripped — files inside land at the project root. So if you drop out/, the platform sees index.html, _next/static/…, etc. at the root, exactly where they need to be.

Server features that won’t work

Even after exporting to static files, anything that needs a Node runtime at request time will not run on the platform:

  • API routes (app/api/*, pages/api/*) — stripped by static export
  • Server components, getServerSideProps, getServerSideProps, on-demand revalidation, ISR
  • next/image server-side optimization
  • Anything that writes its own database tables or files at runtime on a server

Instead, talk to the platform from the browser using the Chinuch SDK (chinuch.getStudents(), chinuch.api, chinuch.ai(), etc.). That’s how your app reads platform data, persists to its own custom tables, and calls AI — without needing its own backend.

Quick recipe: Next.js → static export

// next.config.js
module.exports = {
  output: 'export',          // emit a static site to ./out
  images: { unoptimized: true }, // next/image needs a server, so disable it
  trailingSlash: true,        // friendlier file URLs
}

// then in your terminal:
npm run build                // produces ./out/

// upload the ./out folder in the submission form

Quick recipe: Vite

// vite.config.js — tell Vite that assets live next to index.html
export default {
  base: './',
}

// then:
npm run build                // produces ./dist/

// upload the ./dist folder

The base: './' setting (or assetPrefix in Next.js) matters because your app is served under a path like /api/apps/<id>/serve/, not the site root. Relative paths just work — absolute paths like /assets/x.js won’t resolve.

Quick Start

The simplest possible app — one file:

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My App</title>
  <!-- Both /chinuch-ui.css and /chinuch-sdk.js are auto-injected by the platform,
       but declaring them yourself makes local dev work too. -->
  <link rel="stylesheet" href="/chinuch-ui.css">
  <script src="/chinuch-sdk.js"></script>
</head>
<body class="chi-app">
  <div class="chi-container">
    <header class="chi-header">
      <div class="chi-header-top">
        <h1 class="chi-header-title">Hello from my app!</h1>
      </div>
      <p class="chi-header-sub">Native platform styling — zero setup.</p>
    </header>

    <div id="students" class="chi-grid"></div>
  </div>

  <script>
    chinuch.applyUi(); // idempotent — also fine to just set class="chi-app" on <body>
    chinuch.getStudents().then(function({ data }) {
      var container = document.getElementById('students');
      data.forEach(function(student) {
        var card = document.createElement('div');
        card.className = 'chi-card';
        card.innerHTML =
          '<div class="chi-card-header">' +
            '<div><div class="chi-card-title">' + student.full_name + '</div>' +
            '<div class="chi-card-sub">' + (student.grade_level || '') + '</div></div>' +
            '<span class="chi-pill chi-pill--primary">Student</span>' +
          '</div>';
        container.appendChild(card);
      });
    });
  </script>
</body>
</html>

UI Design System

The platform auto-injects /chinuch-ui.css into every served app page. Add class="chi-app" to your root element (or call chinuch.applyUi()) and your app will pick up the same fonts, spacing, cards, pills, buttons, and headers as the rest of the Chinuch platform — light and dark mode out of the box.

All classes are namespaced chi-* so they won’t collide with your own styles. You can still ship your own CSS alongside it.

AreaClasses
Rootchi-app, chi-app.chi-dark, [data-theme="dark"]
Layoutchi-container, chi-page, chi-stack, chi-stack--sm/--lg, chi-row, chi-row--between, chi-grid, chi-divider
Headerchi-header, chi-header--plain, chi-header-top, chi-header-title, chi-header-count, chi-header-sub, chi-header-actions
Cardschi-card, chi-card--interactive, chi-card--accent, chi-card--flat, chi-card-header, chi-card-title, chi-card-sub, chi-card-body, chi-card-footer
Pillschi-pill, chi-pill--primary, chi-pill--success, chi-pill--warn, chi-pill--danger, chi-pill--info, chi-pill--muted, chi-pill--ghost, chi-pill--solid, chi-dot (+ variants)
Buttonschi-btn, chi-btn--primary, chi-btn--outline, chi-btn--ghost, chi-btn--danger, chi-btn--sm, chi-btn--lg, chi-btn--icon
Formschi-field, chi-label, chi-input, chi-textarea, chi-select
Tableschi-table
Utilitychi-muted, chi-subtle, chi-strong, chi-mono, chi-empty (+ chi-empty-title), chi-spinner

Every example below is rendered live from the real /chinuch-ui.css (same file every app gets) — not a screenshot. Each block shows the result first, then the exact code to copy.

Header + card gridLive

Orders 24

Everything you’ve submitted this week

Pizza Labels
by Ms. Cohen
Approved
Generate printable labels for weekly pizza orders.
Chazara Tracker
by Rabbi Levi
In review
Track daily review cycles for Gemara and Mishna.
Siddur Helper
by Rivky G.
Rejected
Interactive Tefilla with niggun playback and follow-along.
<header class="chi-header">
  <div class="chi-header-top">
    <h1 class="chi-header-title">
      Orders <span class="chi-header-count">24</span>
    </h1>
    <div class="chi-header-actions">
      <button class="chi-btn chi-btn--ghost chi-btn--sm">Refresh</button>
      <button class="chi-btn chi-btn--primary chi-btn--sm">+ New Order</button>
    </div>
  </div>
  <p class="chi-header-sub">Everything you've submitted this week</p>
</header>

<div class="chi-grid">
  <div class="chi-card chi-card--interactive">
    <div class="chi-card-header">
      <div>
        <div class="chi-card-title">Pizza Labels</div>
        <div class="chi-card-sub">by Ms. Cohen</div>
      </div>
      <span class="chi-pill chi-pill--success">
        <span class="chi-dot chi-dot--success"></span> Approved
      </span>
    </div>
    <div class="chi-card-body chi-muted">
      Generate printable labels for weekly pizza orders.
    </div>
    <div class="chi-card-footer">
      <span class="chi-pill chi-pill--muted">tool</span>
      <span class="chi-pill chi-pill--info">teachers</span>
    </div>
  </div>
  <!-- …more cards… -->
</div>
PillsLive
defaultprimarysuccesswarndangerinfomutedghostsolid
<span class="chi-pill">default</span>
<span class="chi-pill chi-pill--primary">primary</span>
<span class="chi-pill chi-pill--success">success</span>
<span class="chi-pill chi-pill--warn">warn</span>
<span class="chi-pill chi-pill--danger">danger</span>
<span class="chi-pill chi-pill--info">info</span>
<span class="chi-pill chi-pill--muted">muted</span>
<span class="chi-pill chi-pill--ghost">ghost</span>
<span class="chi-pill chi-pill--solid">solid</span>
ButtonsLive
<button class="chi-btn">Default</button>
<button class="chi-btn chi-btn--primary">Primary</button>
<button class="chi-btn chi-btn--outline">Outline</button>
<button class="chi-btn chi-btn--ghost">Ghost</button>
<button class="chi-btn chi-btn--danger">Danger</button>
<button class="chi-btn chi-btn--primary" disabled>Disabled</button>

<!-- Sizes -->
<button class="chi-btn chi-btn--primary chi-btn--sm">Small</button>
<button class="chi-btn chi-btn--primary">Medium</button>
<button class="chi-btn chi-btn--primary chi-btn--lg">Large</button>

<!-- Icon-only + loading -->
<button class="chi-btn chi-btn--outline chi-btn--icon" aria-label="refresh">↻</button>
<button class="chi-btn chi-btn--primary">
  <span class="chi-spinner"></span> Saving…
</button>
Form fieldsLive
<form class="chi-stack">
  <div class="chi-field">
    <label class="chi-label">Topping</label>
    <select class="chi-select">
      <option>Cheese</option>
      <option>Mushroom</option>
    </select>
  </div>
  <div class="chi-field">
    <label class="chi-label">Name on order</label>
    <input class="chi-input" placeholder="e.g. Mrs. Cohen">
  </div>
  <div class="chi-field">
    <label class="chi-label">Notes</label>
    <textarea class="chi-textarea" placeholder="Anything to add?"></textarea>
  </div>
  <div class="chi-row">
    <button type="submit" class="chi-btn chi-btn--primary">Save</button>
    <button type="button" class="chi-btn chi-btn--ghost">Cancel</button>
  </div>
</form>
TableLive
StudentClassStatus
Shneur Cohen5Bpaid
Rivky Levi4Apending
Mendel Gruen6Coverdue
<table class="chi-table">
  <thead>
    <tr>
      <th>Student</th>
      <th>Class</th>
      <th>Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td class="chi-strong">Shneur Cohen</td>
      <td class="chi-muted">5B</td>
      <td><span class="chi-pill chi-pill--success">paid</span></td>
    </tr>
    <!-- …more rows… -->
  </tbody>
</table>
Utilities & empty stateLive
Strong textMuted textSubtle textchi-mono sample

No orders yet
Orders submitted by teachers will appear here.
<div class="chi-row">
  <span class="chi-strong">Strong text</span>
  <span class="chi-muted">Muted text</span>
  <span class="chi-subtle">Subtle text</span>
  <code class="chi-mono">chi-mono sample</code>
  <span class="chi-spinner"></span>
</div>
<hr class="chi-divider">
<div class="chi-empty">
  <div class="chi-empty-title">No orders yet</div>
  <div>Orders submitted by teachers will appear here.</div>
</div>

Dark mode

Dark mode tracks the user’s OS preference automatically. To force a theme, add chi-dark to your root (or data-theme="dark" / data-theme="light" on any ancestor).

Dashboard

Same classes — dark palette applied by adding one class.

Enrollments
This week
+12
312 active students across 24 classes.
Attendance
Today
94%
287 present · 14 absent · 11 tardy
<body class="chi-app chi-dark">
  <header class="chi-header">
    <div class="chi-header-top">
      <h1 class="chi-header-title">Dashboard</h1>
      <div class="chi-header-actions">
        <button class="chi-btn chi-btn--ghost chi-btn--sm">Settings</button>
        <button class="chi-btn chi-btn--primary chi-btn--sm">+ New</button>
      </div>
    </div>
    <p class="chi-header-sub">Same classes, dark palette.</p>
  </header>
  …
</body>

SDK Reference

Include chinuch-sdk.js and call any of these methods. All data is filtered to the current user's role and school automatically.

Identity & School

MethodReturnsNote
chinuch.getMe(){ id, full_name, email, role, school_id }Current logged-in user
chinuch.getSchool(){ id, name, logo_url, city, state }Current school info

Student Profile

MethodReturnsNote
chinuch.getStudentProfile(studentId?){ id, first_name, last_name, full_name, gender, birthday_english, address, phones, parents, emergency_contacts, classes, ... }Full student detail. No arg = own profile. Role-checked per student.

Families

MethodReturnsNote
chinuch.getFamilies()[{ id, family_name, primary_phone, primary_email, ... }]Admin only — all families in the school
chinuch.getMyFamily()[{ id, family_name, address, primary_phone, emergency_contacts, ... }]Parent / student — their own family
chinuch.getFamilyMembers(familyId)[{ user_id, role, first_name, last_name, phone, email, is_primary, is_billing_contact, is_emergency_contact, can_pickup, ... }]Members of a family the caller belongs to or admins

Health

MethodReturnsNote
chinuch.getMyHealth(){ health_profile, health_profile_locked, ... }Caller's own health data
chinuch.getHealth(studentId){ health_profile, school_health_notes?, ... }Student health. Admin/teacher also see school_health_notes. Access gated: self / same-family parent / teacher of student / admin.

Billing / Tuition

MethodReturnsNote
chinuch.getBillingEnrollments()[{ id, student_id, parent_id, final_amount_cents, deposit_paid_cents, installment_count, payments_completed, next_payment_date, status, ... }]Parent: own only. Admin: all. Student/teacher forbidden.
chinuch.getBillingProgress()[{ parent_id, student_id, total_amount_cents, amount_paid_cents, amount_remaining_cents, next_payment_date, failed_payments_count, ... }]Tuition progress summary
chinuch.getBillingTransactions()[{ id, amount_cents, fee_cents, net_amount_cents, status, payment_method, card_last_four, card_brand, payment_type, created_at, ... }]Stripe payment history

People

MethodReturnsNote
chinuch.getStudents()[{ id, first_name, last_name, full_name, gender, photo_url, admin_grade_id }]Student=self, teacher=their students, parent=their kids, admin=all
chinuch.getTeachers()[{ id, first_name, last_name, full_name, email, phone, gender }]Admin/school_admin only
chinuch.getParents()[{ id, first_name, last_name, full_name, email, phone }]Role-filtered

Classes

MethodReturnsNote
chinuch.getClasses()[{ id, name, grade_level, gender, subject_id, teacher_id }]Role-filtered. `gender` = class gender segmentation (boys/girls/mixed).
chinuch.getSchedule()[{ id, name, grade_level, subject_id }]Class schedule

Assessments & Testing

MethodReturnsNote
chinuch.getAssessments()[{ id, name, subject_id, status }]Tests and quizzes
chinuch.getAssessmentAttempts()[{ id, assessment_id, student_id, score, max_score, passed, started_at, completed_at, time_spent_seconds }]Every attempt by the current user or their students
chinuch.getAssessmentMastery()[{ student_id, assessment_id, mastered, best_score, attempts_count }]Pass/fail summary per student per assessment

Torah Learning

MethodReturnsNote
chinuch.getChumashProgress()[]Chumash progress per student
chinuch.getGemaraProgress()[]Gemara progress
chinuch.getMishnaProgress()[]Mishna progress
chinuch.getTefilla()[]Tefilla mastery
chinuch.getMilim()[]Hebrew vocabulary
chinuch.getKriah()[]Kriah (reading) level
chinuch.getBrachos()[]Brachos learned
chinuch.getChazara()[]Review sessions completed

Grades & Progress

MethodReturnsNote
chinuch.getReportCards()[{ id, student_id, term_id, class_id, status, attendance_days_present, attendance_days_absent, attendance_percentage, promotion_status, published_at, ... }]One row per student per term. Students see their own; parents see their kids; teachers see their classes; admins see all.
chinuch.getReportCardGrades()[{ id, student_id, report_card_id, term_class_subject_id, raw_score, letter_grade, percentage_grade, points_earned, points_possible, teacher_comment, status, ... }]Per-subject grades across report cards. Same role filter as getReportCards.
chinuch.getProgress()[{ student_id, assessment_id, score, max_score, passed, completed_at }]Completed assessment attempts
chinuch.getStandards()[{ standard_id, chinuch_standards: { name } }]Learning standards covered by assessments
chinuch.getSubjects()[{ id, name, name_he, category }]Subjects taught
chinuch.getCurriculum()[{ id, subject_id, title, description, grade_level, order_index }]Curriculum units

Attendance & Behavior

MethodReturnsNote
chinuch.getAttendance()[{ student_id, date, status, class_id }]Daily records
chinuch.getAttendanceSummary()[{ student_id, present, absent, late, excused, total }]Aggregate counts
chinuch.getBehavior()[{ student_id, type, points, note, logged_at }]Behavior log entries
chinuch.getBehaviorSummary()[{ student_id, total_points, positive_points, negative_points }]Points summary

Communication & Calendar

MethodReturnsNote
chinuch.getAnnouncements()[{ id, title, content, created_at }]School announcements
chinuch.getHomework()[{ id, title, due_date, class_id }]Homework assignments
chinuch.getCalendar()[{ id, title, start_date, end_date, event_type }]Upcoming school events
chinuch.getJewishCalendar(){ data, note }Jewish holidays and parasha

Custom Tables (chinuch.db query builder)

MethodReturnsNote
chinuch.db(table)DbQueryStart a query against a table you declared in schema.json. Every chain ends with .select / .insert / .update / .delete.
db(table).eq(field, value)DbQueryFilter rows. Chainable — multiple .eq() calls AND together.
db(table).join(field, table, [cols])DbQueryPull columns from a related platform table via a uuid FK column.
db(table).orderBy(field, dir)DbQuerySort the select result. dir = 'asc' | 'desc'.
db(table).limit(n)DbQueryCap the number of rows (select only).
db(table).worldwide()DbQueryOpt into cross-school reads on a worldwide-scoped table (needs worldwide-read grant).
db(table).select()Promise<{ data, error }>Terminal. Executes the read.
db(table).insert(row)Promise<{ data, error }>Terminal. Scope columns (school_id / user_id) are filled server-side.
db(table).eq(...).update(patch)Promise<{ data, error }>Terminal. Requires at least one .eq() filter — otherwise 400. Auto-injected columns in the patch are ignored.
db(table).eq(...).delete()Promise<{ success }>Terminal. Requires at least one .eq() filter — otherwise 400.

UI

MethodReturnsNote
chinuch.applyUi({ root?, dark? })voidApply chi-app class; opt into /chinuch-ui.css design system

Security Rules

  • Your app runs in a sandboxed iframe — it cannot access the parent page's cookies or storage
  • Session tokens expire after 1 hour and are never stored persistently
  • Data is always filtered to the current user's school and role
  • Do not attempt to make fetch() calls to external servers with user data
  • Do not include obfuscated code or crypto miners — apps are scanned by AI before review
  • Content must be appropriate for a Chabad Jewish school environment

Custom Database Tables

If your app needs to store its own data (game scores, competition results, leaderboards, etc.), you can request custom database tables by including a schema.json file in your upload. These tables are joined with the Chinuch platform data (students, standards, classes).

Table Scope

Every table must declare its scope — who can see the data:

ScopeWho Sees ItUse When
worldwideAll schools — each school sees only their own entries, but global aggregates (leaderboards) can be queriedMBP competitions, cross-school leaderboards, global challenges
per_schoolEach school's data is completely isolatedClass games, school-specific competitions, private leaderboards
per_userPrivate to each user — only they (and admins) can see itPersonal settings, private notes, individual progress

Allowed Column Types

textintegerbooleantimestampuuidjsonbdecimal

Nullability: columns are nullable by default

Every column you declare is NULLABLE unless you explicitly mark it required. That means an insert or update can leave any field unset and the row still writes successfully. Use "required": true only on fields that truly must be present on every row (for example, a foreign-key column like student_id).

"columns": {
  "student_id":   { "type": "uuid", "references": "students", "required": true },
  "slices":       { "type": "integer", "required": true },
  "paid":         "boolean",          // nullable (default)
  "notes":        "text",             // nullable (default)
  "amount_cents": "integer",          // nullable — price may not be known yet
  "order_date":   "timestamp"         // nullable (default)
}

Legacy shape: "optional": true is still accepted as a synonym for the default nullable behavior, and "optional": false is treated as required, so older schema.json files keep applying.

Allowed Join References

Use uuid columns with a references field to link to platform data:

Real Postgres tables (enforced FK)

classessubjectsschools

Virtual SDK entities (soft link, no FK)

studentsteachersstandards

Why these are different

students, teachers, and standards are not single Postgres tables — the platform assembles them at read-time from user_profiles, student_enrollments, role assignments, and curriculum views. You should still use references: "students" on your uuid column — the admin UI, AI inspector, and SDK .join() helper all understand it as a soft link. The generated SQL keeps the column as uuid with a COMMENT ON COLUMN note, and skips the FK so the migration never fails.

Example: Jeopardy Game

// schema.json
{
  "tables": {
    "jeopardy_questions": {
      "scope": "per_school",
      "description": "Question bank for Jeopardy game",
      "columns": {
        "category":    "text",
        "question":    "text",
        "answer":      "text",
        "points":      "integer",
        "standard_id": { "type": "uuid", "references": "standards" }
      }
    },
    "game_scores": {
      "scope": "per_school",
      "description": "Scores per student per game round",
      "columns": {
        "student_id": { "type": "uuid", "references": "students" },
        "score":      "integer",
        "round":      "text",
        "played_at":  "timestamp"
      }
    }
  }
}

Example: MBP (Mishnayos Baal Peh) Worldwide Competition

// schema.json
{
  "tables": {
    "mbp_completions": {
      "scope": "worldwide",
      "description": "Global MBP completion tracking across all schools",
      "columns": {
        "student_id":   { "type": "uuid", "references": "students" },
        "masechta":     "text",
        "chapter":      "integer",
        "completed_at": "timestamp",
        "verified_by":  { "type": "uuid", "references": "teachers" }
      }
    },
    "mbp_pledges": {
      "scope": "worldwide",
      "description": "Student pledges for upcoming learning",
      "columns": {
        "student_id":  { "type": "uuid", "references": "students" },
        "masechta":    "text",
        "pledged_at":  "timestamp"
      }
    }
  }
}

Reading & Writing Your Tables

Every query starts with chinuch.db('your_table')and then chains one or more of the helpers below. The chain ends with exactly one terminal verb — .select(),.insert(), .update(), or .delete() — which executes the call and returns a Promise.

Chain stepPurpose
.eq(field, value)Filter rows where field equals value. Chainable — call it multiple times to AND filters together.
.join(field, table, [cols])Pull columns from a related platform table via a uuid foreign key column on this row.
.orderBy(field, 'asc' | 'desc')Sort the result set. Only one ordering is supported.
.limit(n)Cap how many rows come back (select only).
.worldwide()Opt in to cross-school reads on a worldwide-scoped table. Requires the worldwide-read grant.
.select()Terminal. Returns { data, error } with the rows.
.insert(row)Terminal. Inserts a single row; scope columns (school_id / user_id) are filled in for you.
.update(patch)Terminal. Updates every row that matches the filters. Requires at least one .eq() — calling it without filters returns a 400.
.delete()Terminal. Deletes every row that matches the filters. Requires at least one .eq() — same rule as update.
// Insert — scope columns (school_id / user_id) are added automatically.
await chinuch.db('game_scores').insert({
  student_id: studentId,
  score: 500,
  round: 'Round 1',
  played_at: new Date().toISOString()
})

// Select — filter, join, order, limit
const { data } = await chinuch.db('game_scores')
  .eq('round', 'Round 1')
  .join('student_id', 'students', ['full_name', 'grade_level'])
  .orderBy('score', 'desc')
  .limit(10)
  .select()

// Update — .eq() filters are REQUIRED. Pass only the fields to change.
await chinuch.db('game_scores')
  .eq('id', scoreId)
  .update({ score: 550 })

// Delete — .eq() filters are REQUIRED. Calling .delete() without any
// .eq() returns a 400 so you can't accidentally wipe the table.
await chinuch.db('game_scores')
  .eq('id', scoreId)
  .delete()

// Worldwide aggregates (requires the worldwide-read grant)
const { data: global } = await chinuch.db('mbp_completions')
  .worldwide()
  .orderBy('completed_at', 'desc')
  .limit(100)
  .select()

Common mistakes to avoid

  • db(t).update(id, patch) is wrong.update() takes only the patch. Filter with .eq('id', id) first.
  • db(t).delete(id) is wrong.delete() takes no args. Filter with .eq('id', id) first.
  • Forgetting .select() at the end of a read. .eq(), .orderBy(), .limit(), etc. are non-terminal — without .select() the query never runs.
  • Trying to write the auto-injected columns (id, school_id, user_id, created_at, updated_at). The server silently strips them from inserts and updates.

Schema Review

Your schema.json is reviewed separately by the admin. They will check that your table design is appropriate, the scope makes sense, and the join references are used correctly. You may be asked to revise the schema before approval.

Environment Variables & Platform AI

Two things live under Environment: your own custom variables (any KEY=value pair your app needs) and a grant to use the platform AI proxy (GPT / Claude) — where the key stays on our server and usage is billed to whichever user is running the app. Read everything at runtime through chinuch.env() and call AI through chinuch.ai().

Custom Environment Variables

On the Environment step you can add any number of KEY=value pairs. Rules:

  • Keys must be UPPER_SNAKE_CASE and start with a letter (e.g. STRIPE_PUBLISHABLE_KEY, MY_API_BASE).
  • Values are stored in a row-level-secured Postgres table — only you (the submitter) and Chinuch admins can read them.
  • Mark a variable as Secret (the default) to hide its value in the wizard and in the admin review panel.
  • A description field helps reviewers understand what each variable is for.

Platform AI (GPT & Claude)

Need to call GPT or Claude? You don't have to bring your own API key — tick the checkbox for either provider in the Environment step. Chinuch keeps the real key on the server and exposes a single proxy at POST /api/v1/ai, or more easily chinuch.ai(...) from the SDK.

Grant keyServiceBilled as
openaiOpenAI (GPT-4o, GPT-3.5, embeddings)Free tier for gpt-4o / gpt-3.5; usage logged
anthropicAnthropic (Claude Sonnet & Opus)Sonnet free tier; Opus deducts purchased credits

How billing works

Every chinuch.ai() call is metered against the running user's AI credits — the same balance they see at Settings → AI Credits, and the same table (user_ai_credits) that powers the built-in teacher tools. New users get a free tier; premium models (e.g. Claude Opus) deduct from the purchased balance. If the user is out of credits, the call returns HTTP 402. Your app should handle that gracefully (e.g. fall back to a non-AI flow or prompt the user to top up).

Platform API keys themselves never leave the server. If you try to read them through chinuch.env(), you'll see grant metadata (label, availability) only — not the key.

Reading Env & Calling AI

// 1. Custom env vars (already decoded)
const { data } = await chinuch.env()
//   data.env           → { MY_API_BASE: "https://…", FEATURE_FLAG: "on", … }
//   data.platformGrants → [{ key: "openai", available: true }, …]
console.log(data.env.MY_API_BASE)

// 2. Call a platform AI model — no key in your code, billed to the user
const canUseGpt = data.platformGrants.some(g => g.key === 'openai' && g.available)
if (canUseGpt) {
  const r = await chinuch.ai({
    provider: 'openai',
    model:    'gpt-4o-mini',
    systemPrompt: 'You are a helpful tutor for a 5th-grader.',
    messages: [{ role: 'user', content: 'Explain photosynthesis briefly.' }],
    maxTokens: 400,
  })
  if (r.error) {
    // 402 = out of credits, 403 = no grant, 502 = upstream error
    console.error(r.error)
  } else {
    console.log(r.data.content)
    console.log('Tokens:', r.usage.totalTokens,
                'credits left:', r.usage.creditsRemaining)
  }
}

Placeholders used in your HTML/JS that look like {{MY_API_KEY}} are not substituted at upload time. Always read values at runtime through chinuch.env() so you can rotate configuration without re-uploading the app.

Editing Later

You can revisit the Environment step from My Apps → your app → Environment at any time. Updates take effect immediately for running sessions. If your app was already approved, changing env values or platform grants will automatically re-submit it for a fresh security scan and admin review — because the reviewed state is code plus env, not code alone.

Security model — read this before storing anything sensitive

  • Nothing returned by chinuch.env() is actually secret from end-users. The value lands in the browser running your app; any student or teacher using the app can read it from DevTools. The "Secret" toggle only hides the value from other admins inside the submission UI.
  • For real secrets (OpenAI, Stripe, SendGrid, etc.) request a Platform Grant. The key stays on our servers and your app calls the corresponding proxy endpoint — the raw key never reaches the browser.
  • Hard limits per app: max 30 env vars, max 4,000 characters per value, max 500 characters per description. No NUL bytes.
  • Our security scanner inspects both your code and the env values together. Common abuse patterns are auto-rejected, including: sending env values to an external origin, using env values inside eval/new Function/innerHTML/document.write, or redirecting the window to an env-derived URL. Env values that look like external URLs, <script> fragments, javascript:/data: URIs or long base64 blobs are flagged for manual review.
  • Overbroad platform grants (requesting many high-privilege services without a clear need) will be flagged for manual admin review.

AI App Inspection

When you submit your app, our admin team can run an AI App Inspection that checks:

Security

XSS, data exfiltration, obfuscated code

Code Quality

Clean, readable, no bugs

Performance

File size, no memory leaks

Design & UX

Mobile-responsive, accessible

Tochen

Appropriate for Orthodox Jewish school

Schema

Correct table design and scope

Build

index.html present, no broken imports

The inspection gives a score out of 10 per category and flags any issues. Addressing the flagged items will speed up your approval.

Ready to Submit?

Upload your app files (and optional schema.json) and submit for review. Our team will review and approve within a few days.

Submit Your App →