Custom App Developer Guide
Build and publish apps on the Chinuch platform — for teachers, students, and school admins.
How It Works
- Build your app as a folder of static files (HTML, CSS, JS)
- Upload the files through the submission form
- An AI security scan runs automatically on your code
- Our admin team reviews and approves your app
- Approved apps appear in the marketplace for schools to install
- When a user opens your app, they see it embedded in the Chinuch platform
Requirements
index.htmlat 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.
What to upload by framework
| Framework | Build command | Folder 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 build | dist/ |
| Create React App | npm run build | build/ |
| Astro (static) | npm run build | dist/ |
| SvelteKit (static adapter) | npm run build | build/ |
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/imageserver-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 formQuick 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 folderThe 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.
| Area | Classes |
|---|---|
| Root | chi-app, chi-app.chi-dark, [data-theme="dark"] |
| Layout | chi-container, chi-page, chi-stack, chi-stack--sm/--lg, chi-row, chi-row--between, chi-grid, chi-divider |
| Header | chi-header, chi-header--plain, chi-header-top, chi-header-title, chi-header-count, chi-header-sub, chi-header-actions |
| Cards | chi-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 |
| Pills | chi-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) |
| Buttons | chi-btn, chi-btn--primary, chi-btn--outline, chi-btn--ghost, chi-btn--danger, chi-btn--sm, chi-btn--lg, chi-btn--icon |
| Forms | chi-field, chi-label, chi-input, chi-textarea, chi-select |
| Tables | chi-table |
| Utility | chi-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.
Orders 24
Everything you’ve submitted this week
<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><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>
<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 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><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>chi-mono sample<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.
<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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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
| Method | Returns | Note |
|---|---|---|
| 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)
| Method | Returns | Note |
|---|---|---|
| chinuch.db(table) | DbQuery | Start a query against a table you declared in schema.json. Every chain ends with .select / .insert / .update / .delete. |
| db(table).eq(field, value) | DbQuery | Filter rows. Chainable — multiple .eq() calls AND together. |
| db(table).join(field, table, [cols]) | DbQuery | Pull columns from a related platform table via a uuid FK column. |
| db(table).orderBy(field, dir) | DbQuery | Sort the select result. dir = 'asc' | 'desc'. |
| db(table).limit(n) | DbQuery | Cap the number of rows (select only). |
| db(table).worldwide() | DbQuery | Opt 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
| Method | Returns | Note |
|---|---|---|
| chinuch.applyUi({ root?, dark? }) | void | Apply 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:
| Scope | Who Sees It | Use When |
|---|---|---|
| worldwide | All schools — each school sees only their own entries, but global aggregates (leaderboards) can be queried | MBP competitions, cross-school leaderboards, global challenges |
| per_school | Each school's data is completely isolated | Class games, school-specific competitions, private leaderboards |
| per_user | Private to each user — only they (and admins) can see it | Personal settings, private notes, individual progress |
Allowed Column Types
textintegerbooleantimestampuuidjsonbdecimalNullability: 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)
→ classes→ subjects→ schoolsVirtual SDK entities (soft link, no FK)
→ students→ teachers→ standardsWhy 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 step | Purpose |
|---|---|
| .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_CASEand 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 key | Service | Billed as |
|---|---|---|
| openai | OpenAI (GPT-4o, GPT-3.5, embeddings) | Free tier for gpt-4o / gpt-3.5; usage logged |
| anthropic | Anthropic (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.