Getting started

Okta Partner App Developer Guide

Aimed at developers building an app that integrates with the Okta platform. Assumes basic familiarity with HTTP/JSON and a server framework of your choice.

Table of contents

  1. Integration types
  2. Quick start
  3. Scopes
  4. Platform accounts: who opens your app
  5. Companies and complexes: the tree and its schools' data
  6. App lifecycle
  7. Embedded apps
  8. Your app's database (migrations)
  9. Extending the student profile
  10. Landing widgets
  11. External apps
  12. Notification apps
  13. Payment apps
  14. Browser tools
  15. Manifest
  16. API
  17. Webhooks
  18. Notifications catalog
  19. Security
  20. Local testing
  21. AI-assisted development (MCP)
  22. Design system & UI
  23. AI support
  24. Okta mobile app
  25. Instant messages (realtime)
  26. Audible announcements (audio & speech)
  27. Voice notes and file uploads
  28. Okta's identity inside a mini-app (Liquid Glass)
  29. Example: an attendance-by-scan app (native)
  30. Example: Daycare Operations app (External)
  31. FAQ

Integration types

When you create a new app the very first thing you'll choose is the integration type. Everything else flows from this decision.

Embedded

Your app is shipped into okta-web as code and runs in the same process.

Hosted by Okta
UI Livewire/Blade rendered inside the tenant dashboard
Communication In-process calls into App\Services\PartnerApi\*
Repository Dedicated GitHub repo with ready-made boilerplate
Pro Seamless tenant UX, low latency
Con Strict isolation rules — must go through PartnerApi

External

Your app is hosted by you and talks to Okta exclusively over HTTP.

Hosted by You
UI Yours (web/mobile/server-only)
Communication REST API + signed webhooks
Repository None — no source code lives at Okta
Pro Full freedom over your stack
Con You manage hosting, secrets, webhook security

Notification (provider)

A pluggable notification provider (WhatsApp / SMS / Push / Slack / ...) that the platform consumes through one unified send(recipient, message) interface, regardless of the underlying transport.

Hosted by You (api), Okta (embedded), or both (hybrid)
UI None — server-only app invoked by the rest of the platform
Communication Single unified call from the platform per recipient
Pro Adds a new channel (e.g. WhatsApp Cloud, Twilio) without core changes
Con The platform owns the contract — you can't extend it on your own

Three delivery modes:

  • api — Okta POSTs to your endpoint with HMAC.
  • embedded — you ship a class inside okta-web that implements PartnerNotificationProvider.
  • hybrid — embedded as the primary, falls back to api on failure.

Rule of thumb: Pick Embedded for features that should feel like part of Okta (e.g. a custom reports tab). Pick External when you need an independent service (e.g. linking Okta to a system you already operate). Pick Notification when you only want to add a delivery channel — no UI, no workflow, just a way for the rest of the platform to reach users on a new medium.

Payment (provider)

A payment gateway/method (Tabby / Tamara / Noon Payments / ...) a tenant installs once, after which any other app charges through it via a uniform charge(...) contract. Shaped like a notification provider, but the verb is collecting money rather than sending messages. Same three delivery modes: api / embedded / hybrid. Consuming this contract (charging from another app) is available exclusively to Embedded apps — see Consuming payments from your app. Full details in Payment apps.


Quick start

1. Create a partner account

Sign up through the partner portal and complete your company profile. You'll receive a tenant of your own that hosts every app you build.

2. Create your first app

From Apps → New app:

  • Pick the integration type.
  • Fill in basics (name, description, category, icon).
  • For Embedded: connect GitHub so we can provision the repo.
  • For External: enter webhook_url and the events you want.
  • Pick the scopes you need from the catalog.

3. Submit for review

Hit Submit for review when the version is ready. Our team checks the manifest, runs the policy scanner against your repo (Embedded), and probes your webhook URL (External). Reviews typically come back in 1-3 business days.

4. Approved

The app publishes automatically to the marketplace. Tenants can install it, approve the scopes you requested, and start using it.


Scopes

Every piece of data in Okta is gated by a scope of the form:

<feature>.<resource>.<action>

Examples:

education.students.read       → list/show students
education.students.write      → add/update students (no delete)
education.face_embeddings.read → face templates (dangerous, read-only)
employees.directory.read      → list/show employees
reports.builder.read          → run curated reports
reports.builder.write         → propose letterheads (not reports)

Rules:

  1. lowercase + dot-separated with no wildcards.
  2. Partners only ever get read or write. Delete is never granted to partners.
  3. The catalog is the single source of truth. You can't invent new scopes.
  4. Okta adds new scopes through platform updates.

Pull the live catalog:

GET /api/partners/permissions/catalog

For the full per-resource scope reference (endpoints + PHP/HTTP examples), see commit a164484 on prod — the content is unchanged.

education.face_embeddings.read — face templates

A dangerous scope. The install screen paints it in the danger colours and the tenant admin will stop and read it. Request it only if your app genuinely matches faces, and write a reason a head teacher can understand.

GET /api/apps/education/face-embeddings?model=AdaFace1&page=1&per_page=50

Returns the tenant's enrolled face templates as raw numeric vectors, so your app can match them on a machine in the building.

There is no .write tier and there never will be. Enrolment happens in the platform with the person standing at a desk; an app able to write a template could make any camera recognise anybody as anybody.

Why it is separate from education.student_photos.read. They look like the same consent and are not. A photograph is what the school already prints on a card and shows at a gate. A template is a key that matches this child against any camera anywhere — and unlike a password it cannot be reissued after a leak. A tenant that agreed to show faces has said nothing about handing over the mathematics that recognises them.

Four things to know before you integrate:

  • model is required and has no default. AdaFace1 and ArcFace_50r1 produce vectors that are not comparable with each other. If the platform picked one for you, you would receive templates your engine silently fails to match — with no error anywhere.
  • count is not total. count is what this page carries after released students and undecryptable rows drop out; total is what the tenant holds. Page on total — deriving it from count stops your loop early on the first page that happens to contain a released student.
  • No names come back. You get vectors keyed by ULID. If you need to know whose face you matched, hold education.students.read as well and resolve the ULID there. One grant does not quietly deliver two.
  • Matching happens on your side, not the platform's. The vectors are handed over raw because the comparison has to happen where the camera is — a gate, in the building, usually offline. An endpoint returning similarity scores would require the network the gate does not have.

From a mini-app this needs no special symbol in okta_host — the host allow-list already passes /api/apps/*:

final res = await Okta.get('/api/apps/education/face-embeddings?model=AdaFace1');

The organisation's clock — /api/apps/tenant/clock

No scope. Every installed app reaches it with its installation token alone, and nothing is asked of the organisation.

That is a decision rather than an omission: a timezone is not the organisation's data, it is the frame its data is read in — like the locale. Your app already receives that school's timestamps, so there is nothing here a grant could protect. Putting a scope in front of it would buy zero privacy and cost something real: apps rendering times in the wrong zone because nobody thought to request a grant nobody thought to give. A register shown an hour out does not look broken — which is what makes that failure expensive.

Method Path Scope Description
GET /api/apps/tenant/clock — The wall clock the organisation reads its day by

Response:

{
  "timezone": "Asia/Riyadh",
  "offset": "+03:00",
  "now": "2026-09-02T14:31:07+03:00"
}

Store timezone, never offset

offset and now describe this instant, not this place. Only the name carries the rules, so it stays right when the rules change under it.

This is not hypothetical — one supported country observes daylight saving:

Zone January July
Asia/Riyadh +03:00 +03:00
Asia/Dubai +04:00 +04:00
Africa/Cairo +02:00 +03:00

An app that stored +02:00 for a Cairo school in January is a full hour wrong in April — on every screen, with no error in any log, the date right, the format right, and only the hour false. Had it stored "Africa/Cairo" it would have moved with the country on its own.

Use offset to show beside a time, or to hand to something that cannot take a zone name. Nothing else.

Storage stays UTC

Every timestamp the platform hands you, and every one you hand back, is UTC — and that does not change. This endpoint answers a different question: what to display. Convert on the way out, never on the way in; a value converted at write time is one nobody can compare across two schools afterwards, and the damage is invisible until two schools appear in the same report.

Where the value comes from

The school picks it in its own settings. If it has not, the value is derived from its country when that country has exactly one zone, and otherwise from the platform default. You always receive the final answer — so do not treat "no explicit choice" as a special case.

Embedded (PHP):

use App\Services\PartnerApi\Tenants\GetTenantClock;

$clock = app(GetTenantClock::class)();

$clock->timezone;   // "Asia/Riyadh" — this is what you store
$clock->offset;     // "+03:00" — display only
$clock->now;        // ISO8601 on the school's clock

// And a stored UTC moment read on their clock:
app(GetTenantClock::class)->at($row->created_at)?->format('Y-m-d H:i');

From a mini-app (Dart) — needs no new symbol in okta_host and no minContract bump:

final res = await Okta.get('/api/apps/tenant/clock');
if (res.status == 200) {
  final zone = res.body['timezone'];   // store this
}

Common uses: printing attendance times as the person who marked them saw them, deciding what "today" means for the school, scheduling a reminder for the local morning rather than the server's.

Do not ask once and store now. "What time is it" is a question you ask when you need the answer; the name is the only part worth keeping.


Identifiers and core_reference

Every response leaving the platform replaces numeric IDs with ULIDs. A core_reference is a column in your table holding one of them: a 26-character string pointing at a row in a platform table on okta-web (students, subjects, grades, sections, tenants, tenant_employees, users).

Why there is no real FK constraint: the referenced row lives in a different database, on okta-web, and Postgres has no cross-database foreign keys. So what you store is a plain char(26) / varchar(26) with no constraint at all — and that is exactly the problem: char(26) is a width you may legitimately use for anything else (a coupon code, an external invoice number, a hash prefix). The width alone is not evidence, and treating every char(26) as a platform reference would reject your legitimate writes.

So the guard does not guess: you must annotate the column explicitly in the migration.

The annotation — two forms

Form A — a column comment in the Postgres catalog (preferred), because it survives into the live database where okta-web and the diff tooling can read it back:

CREATE TABLE "students" (
    "id"           BIGSERIAL PRIMARY KEY,
    "owner_ulid"   CHAR(26) NOT NULL,
    "teacher_ulid" CHAR(26)
);

COMMENT ON COLUMN "students"."owner_ulid"   IS 'core_reference:tenants';
COMMENT ON COLUMN "students"."teacher_ulid" IS 'core_reference:users';

In a Laravel migration that is ->comment():

$table->string('owner_ulid', 26)->index()->comment('core_reference:tenants');

Form B — a SQL comment on the column's own line, inside CREATE TABLE or on a single-line ALTER TABLE … ADD COLUMN:

CREATE TABLE "students" (
    "id"         BIGSERIAL PRIMARY KEY,
    "owner_ulid" CHAR(26) NOT NULL, -- core_reference: tenants
);

ALTER TABLE "students" ADD COLUMN "teacher_ulid" CHAR(26); -- core_reference: users

The separator is flexible: core_reference: tenants, core_reference=tenants and core_reference(tenants) are all accepted. A bare core_reference with no target still counts as an annotation (the ULID-format rule applies whichever platform table is referenced) — but always name the target, since that is what lands in the manifest.

What happens without an annotation

Nothing is rejected — that is the dangerous part. The column simply becomes invisible to the guard, so it is not protected:

  • App\Services\Partners\Sandbox\ValidateCoreReferenceWrites does not know the column is a platform reference, so any value you write into it passes without a ULID-format check and without checking the target table is allowed.
  • The manifest's core_references block comes out empty for your unannotated columns.

Unannotated 26-wide columns are reported separately as candidates and shown in the portal — a heads-up, not a rejection. The difference between "this table has no platform references" and "it may have ones we cannot see" is yours to settle, by annotating.

Path-based migrations (a .php file living in your repo) carry no SQL that can be inspected at check time; they are counted as unreadable and reported as unchecked. Do not assume full coverage with them.

Forbidden (annotated or not):

  • (int) $row->student_id_ulid — casting it to an integer.
  • where('student_id_ulid', 12345) — comparing it to a numeric literal.

Both are caught by the boilerplate scanner and fail CI. Always store and pass it as a string.

Platform accounts: who opens your app

Scopes answer "what data does your app read". This section answers something else entirely: who is sitting in front of the screen. The platform knows four accounts, and they do not work by one mechanism — that difference breaks more assumptions than anything else in the integration.

Account Recorded in Scope Entity in the context How you address it
Teacher / administrator tenant_employees + a role in the entity tenant tenant_id "roles": ["teacher"]
Student tenant_students general portal_tenant_id only "portal": "student"
Guardian tenant_guardians general portal_tenant_id only "portal": "guardian"
Guardian delegate a delegation row — no membership row of their own general portal_tenant_id only "portal": "guardian-delegate"

1. The employee — a role inside the entity

Teachers and administrators live inside the entity: a row in tenant_employees carrying a type (their job), a membership in the entity, and a role. All three are needed — the membership is what makes the entity appear in the context picker, and the role is what actually lets them in.

A member of staff is one type: the school records them as a teacher or as an administrator — not both, and not "employee" beside either, since either one is an employee already — and they are granted the teacher or administrator role. It carries no permissions at all: a key to the door and nothing more. What they may DO is decided afterwards, by attaching permissions to that role or by putting them in an administrator group.

Inside your app this is the "ordinary" account: Tenant::current() is set, the scope is tenant, and everything you know about entity context holds.

Matching is wider than you would guess. "roles": ["teacher"] is matched against three things at once: the active role, every role selected in the session, and tenant_employees.type itself. All of them are normalised before comparison (lowercased, spaces and _ folded to -), so "Teacher" and "teacher" are the same thing.

2 and 3. Student and guardian — portals, not roles

Here everything changes. A student and a guardian have no role row at all: the portal's name IS the audience. The scope is general, tenant_id in the context is empty, and the entity lives under a different key entirely: portal_tenant_id.

This is not a naming detail. A portal account has no entity context in the usual sense. The platform boots its entity for that one request, and only on the app pages your manifest declared; outside them it stays entity-less, and the platform's own entity-scoped pages keep refusing it. Any code assuming Tenant::current() is always there will fall over on a portal page.

And a guardian crosses entities: they may have children in two schools, so they pick the entity on the way in — and that entity is the one the sidebar carries and the one apps open against.

4. The guardian's delegate — a portal of its own, not a flavour of guardian

A guardian can authorise somebody to act for their children: a relative who collects a child from the gate, a driver. The delegation is not a second guardian account; it is a slice of that one parent's own authority:

  • over named children — the parent lists them one by one, never "all of mine";
  • with a subset of their capabilities — they choose what to hand over (the pickup call, attendance, …);
  • and usually until a date — a delegation for exam week ends with it.

A delegate holds no membership row at all: no tenant_guardians, no tenant_students. Their entire existence in that school is a delegation row, so they disappear on their own when it expires, is revoked, or the delegating parent loses their own link to the child.

Why a portal of its own rather than a flavour of guardian: an app that serves parents does not thereby serve whoever a parent handed a key to — it has to SAY it serves them. Had a delegate arrived under the guardian key, they would have matched every guardian audience published today without anybody declaring anything. The cost is stated plainly: your app does not reach delegates until it declares this audience and publishes.

"account_types": [
  { "key": "guardian", "kind": "dependent", "portal": "guardian",
    "web_route": "call.family", "mobile_entry": "okta_app/webview/family.html" },
  { "key": "guardian-delegate", "kind": "dependent", "portal": "guardian-delegate",
    "web_route": "call.delegate", "mobile_entry": "okta_app/webview/delegate.html" }
]

The platform enforces the bounds; you draw them. Refusing is not on you: student reads in a delegate's context are narrowed SERVER-SIDE to the children of that delegation — ListStudents returns those and no others, GetStudent for a pupil outside them answers the same 404 an unknown id gets (so the refusal never tells the delegate who else is enrolled), and the delegating parent's own children list comes back with the delegated children rather than their whole family. A delegation that has lapsed reaches nothing: it is pinned to nobody, not unpinned.

What you draw is the button. $viewer in a delegate's context carries:

What you read What it means
$viewer->type / accountType guardian_delegate
$viewer->isDelegate() true — the branch you draw the delegate screen on
$viewer->delegatedChildren() [{id, capabilities, expiresAt}] — id is the same student id the student services return, so you join on it directly
$viewer->delegatedChildIds() just the ids, for filtering your own rows
$viewer->mayForChild($childId, 'okta_hdor.pickup.call') may they do this, for this child, today
@if ($viewer->isDelegate())
    @foreach ($viewer->delegatedChildren() as $child)
        {{-- draw the card, and the call button only for whoever holds it --}}
        @if ($viewer->mayForChild($child['id'], 'okta_hdor.pickup.call'))
            <x-button wire:click="call('{{ $child['id'] }}')">{{ __('Call') }}</x-button>
        @endif
    @endforeach
@endif
  • delegatedChildren() is never empty in a delegate's context: a delegate with nothing effective left is answered $viewer === null, exactly as a released guardian is. It is a list to DRAW, not a list to test for emptiness.
  • expiresAt is when the delegation ends (null for one that does not) — show it: "ends Thursday" explains the button's disappearance the day after.
  • mayForChild is for drawing, not for stopping: the platform refuses the action outside the delegation itself, so a screen that forgets the check still cannot act. For anybody who is not a delegate it answers false, so gate a parent's own screen on being a guardian rather than on this.

Every capability is yours — the platform declares none

Every capability a parent can hand to a delegate is declared by an installed app. There is no platform list, and you will not find one to choose from.

There was one: nine fixed capabilities (pickup_student, view_marks, pay_fees and the rest) shown to every parent at every school. Nothing read them — no screen, no endpoint and no middleware on the platform ever asked whether a delegate held one. The only reader was, and is, mayForChild() in an installed app. And the first app that needed a capability declared its own rather than use pickup_student — two checkboxes for one act, and the parent could not tell which the school would honour. So the vocabulary went to whoever enforces it.

What that means for you: an organisation where no installed app declares a capability shows the parent no checkboxes at all, and the delegation still means «this person reaches these children». If you want a parent to be able to hand on something of yours, declare it — otherwise nothing is handed on.

Declare them in manifest.json, the family-side twin of rbac_permissions:

"guardian_capabilities": [
  { "key": "okta_hdor.pickup.call",
    "label": { "ar": "إجراء النداء", "en": "Place the pickup call" } },

  { "key": "okta_hdor.attendance.read",
    "label": { "ar": "عرض حضور الابن", "en": "View attendance" },
    "gates": ["okta_hdor.attendance.read"] }
]
  • The key is <feature>.<resource>.<action> with feature = your slug in snake_case (okta-hdor → okta_hdor) — the same rule rbac_permissions carries, enforced just as hard. Not for tidiness: the key is stored on a FAMILY'S DELEGATION and read back by whichever app asks, so declaring in another app's namespace would put a checkbox into the guardian's screen on that partner's behalf, under a heading the parent reads as the platform's. A key outside your namespace is refused by name.
  • label.ar is required — it is the whole of what a parent reads beside the checkbox, and an unlabelled box is one nobody can tick honestly. label.en falls back to the Arabic.
  • The school neither grants nor withholds them, and is not asked. A guardian holds every one by being a guardian in that organisation, and the only decision is theirs: which to hand on. The bound that remains is the guardian's own link to the child — a child their parent no longer reaches drops out of the delegation entirely.
  • The school reads what happened without taking part: a «Delegates» page in the organisation's settings lists who authorised whom, over which children, with which capabilities and until when. Read-only.
  • They appear to the parent grouped under your app's name, in the delegation screen at every organisation that installed you. The bounds still hold: the delegation itself, its end date, and the children it names.

Read them at runtime with the same function:

if ($viewer->mayForChild($childId, 'okta_hdor.pickup.call')) {
    // draw the call button for this child
}
  • And nobody is authorized over themselves: a delegation naming a child whose account IS the delegate is refused on create and on edit, and the children picker does not offer them at all.

gates is optional, and leaving it empty is the ordinary case: you read the capability yourself and draw on it, and nothing changes for anything already published. Naming YOUR OWN scopes there moves the refusal to the PLATFORM: a call using that scope in a delegate's context, by somebody who was not granted the capability, is refused before it reaches your code. Opt-in per capability, so nothing already shipped changes behaviour.

The block reaches a version from your repo through «Sync from manifest» or the sync_from_manifest tool, and is read and written with get_guardian_capabilities / set_guardian_capabilities. Absent, it is left alone: a repo that stops naming the key does not silently withdraw capabilities families have already been offered.

Declaring your audience

The "Account types" tab in the version editor — or set_account_types over MCP — is what becomes menu.audiences[] in okta-web and mobile.audiences[] in the Okta app. For each type:

"account_types": [
  { "key": "teacher",  "kind": "primary",   "roles": ["teacher"], "web_route": "school-app.staff" },
  { "key": "guardian", "kind": "dependent", "portal": "guardian", "web_route": "school-app.family" }
]
  • kind — primary (the main audience) or dependent.
  • Exactly one target: roles (roles inside the entity) or portal.
  • At least one surface: web_route and/or mobile_entry.

web_route is a Laravel route NAME — not a URL, not a /path — and it is a namespace bound: school-app.admin covers school-app.admin.reports and does NOT cover school-app.admins. The segment shape carries meaning; don't write it casually.

The third target: permissions[] — what the school granted, not your guess at the role

The role names in roles[] are your guess at what schools call things, and a school creates its own custom roles and attaches staff to them: "Accounting", "Staff affairs", "Attendance supervisor". So an employee whose account type is administrator, given the management permissions through a custom role, was still routed to the administrators' screen — because the launcher picked the route by role alone.

Hence a third target for an audience: the permission.

"account_types": [
  { "key": "management", "kind": "primary",   "permissions": ["okta_hr.management.view"], "web_route": "okta-hr.admin" },
  { "key": "administrator", "kind": "dependent", "roles": ["administrator"], "web_route": "okta-hr.staff" }
]
  • It matches whoever holds ANY of the named permissions in the entity — read from the tables at request time, whether it came from their base role or from a custom role the school created and attached them to. The name of that role, and how it differs between schools, is none of your business.
  • The names are your own permissions in full <feature>.<resource>.<action> form, and declare them in rbac_permissions too — otherwise the school cannot grant them to anybody.
  • Order is the decision: the launcher links the tile to the first matching audience (primary before dependent, then declaration order). Put the permission audience before the role audiences so it wins for people who match both.
  • A standalone target: never together with roles[] or portal (a portal account holds no permission in the entity at all), and it needs web_route.
  • okta-web only: the Okta app matches its cards by role and portal, and a permission holder gets the general staff card there — so no mobile_entry on this type.

This does not replace can() inside the screen: the audience decides where someone enters, the permission decides what they see and do once inside.

Entity type: entity_types — a complex's admin gets a different screen from a school's

A tenant admin at a complex runs schools; a tenant admin at a school runs pupils — one account type (tenant-admin), not one screen. The only way used to be a single package that asks what kind of entity it is standing in and branches, so the complex's screen was downloaded and compiled on every school's phone. Now you declare two rows for the same type, and the platform picks by the kind of entity the person is standing in:

"account_types": [
  { "key": "tenant-admin", "roles": ["tenant-admin"],
    "web_route": "okta-hr.dashboard",
    "mobile_entry": "okta_app/native/main/lib/main.dart" },
  { "key": "tenant-admin", "roles": ["tenant-admin"],
    "entity_types": ["complex", "education_company"],
    "web_route": "okta-hr.group",
    "mobile_entry": "okta_app/native/group/lib/main.dart" }
]

In manifest.json it is entityTypes on the audience, in menu.audiences[] and mobile.audiences[] alike:

{ "key": "tenant-admin@complex+education_company", "kind": "primary",
  "roles": ["tenant-admin"], "entityTypes": ["complex", "education_company"],
  "entry": "okta_app/native/group/lib/main.dart" }
  • The narrowest wins, not the first. Where the narrowed row applies it wins over the same type's unnarrowed row whatever the declaration order, and the unnarrowed row serves every other kind of entity. So the usual shape is two rows: a general one for every entity, and a narrowed one where a screen of its own is needed.
  • No entity_types = every kind of entity, which is every app published before the field: its manifest does not change by a byte, and neither does what anyone sees.
  • A closed vocabulary: individual_teacher, school, complex, college, university, institute, academy, education_company, daycare_center. An unknown name is refused by name, not dropped: dropping it narrows the row silently, and dropping its last name widens it to every kind of entity — the exact opposite of what was written.
  • Staff only: on roles and permissions. Refused on a portal: students, guardians and delegates reach across entities (children in two schools), and their audience is not chosen by where they happen to stand.
  • One account type in one kind of entity = one screen: two rows of the same type naming the same kind of entity are refused on the same surface, because the second is a screen nobody standing there would reach. The two surfaces are independent: you may narrow okta-web and not the Okta app.
  • The key is <role>@<types> (tenant-admin@complex+education_company), derived by the editor and by set_account_types because one type is now two rows. With set_account_types, pass the bare role or the derived key.
  • The entity's type is read from its record, never from the request: no header or parameter changes which screen opens. An entity whose type the platform does not know (a record older than the vocabulary) is reached by the general row only.
  • No contract bump, no Okta-app update: the server picks, and the phone receives one entry exactly as it does today. Each narrowed row has its own entry, floor (min_contract) and versions[] rows.
  • A permission holder (someone who gets in through one of your permissions rather than their role) is judged in the entity they stand in: a row for complexes does not take a school's only screen away.

The simulator runs on a «school» sandbox, so it mounts the school's entry and lists the narrowed rows by name. Try the complex's screen on a sandbox of type «Educational Complex» (the type is chosen when the sandbox is set up) and sign in to it from a phone.

The trap: publishes cleanly, then never works

A portal key inside roles[]. "roles": ["student"] looks perfectly fine — student is a known catalog key — but the platform matches a portal user on the portal field alone and never on role names. So the declaration matches nobody: the version publishes, the manifest looks healthy, and then every page of that audience is refused for every user. And "custom": true does not rescue it — a portal is not a tenant-defined role, and the platform rejects custom on a portal audience outright.

An invented or mistyped role key ("staff", say) fails the same silent way. Entities define their own roles, so a key outside the catalog is allowed — but only when declared as such: "custom": true. Without it, it is refused on purpose, because the alternative is finding out from a school's complaint rather than from a publish error.

Enforcement is real, not concealment

EnsureAppAudience is applied to the entire web group — with no cooperation from you — and to Livewire round-trips as well, via persistent middleware that re-checks against the originating page's route. Guarding the page alone would have left the windows open: every action after render is a POST to a different route.

And the guard refuses by default. Before it, the launcher merely hid the link, and anyone who knew the path and typed it into the address bar was served.

Who is looking at your app right now — the user's ULID

Knowing the account type is not enough: your app needs to know which person, to show them what is theirs — this teacher's classes, this guardian's children.

use App\Services\PartnerApi\Identity\GetCurrentViewer;

$viewer = app(GetCurrentViewer::class)();

if ($viewer === null) {
    return; // nobody of the three types is looking.
}

$viewer->type;        // employee | student | guardian
$viewer->id;          // ULID — this is what you store and join on
$viewer->displayName;
$viewer->roles;       // raw role keys — employees only
$viewer->entityId;    // the entity's ULID
$viewer->accountType; // tenant_admin | administrator | teacher | employee | student | guardian
$viewer->groups;      // [{key, title}] administrator groups the school made — for display
$viewer->hasStaffRecord(); // does the entity have them in its staff directory?

Staff with no row in the staff directory — hasStaffRecord()

Every entity has staff it never entered as staff: the account that registered the school (nobody adds the owner to the directory), a member attached straight to the entity, and both sandbox accounts you develop against. For them you get a full viewer — account type, groups, permissions, entity — but id comes back empty and hasStaffRecord() answers false, because there is no directory row to join to.

So read it before joining on id; can() and accountType you ask without a second thought. (Before this they were all answered null, which made the API dead on the sandbox itself.)

Do not use auth()->id() for this

The scanner does not stop you, but it is an internal numeric platform key, and the contract is that numeric ids never cross the partner boundary. More to the point, it is useless to you: it is the same integer whether the person entered as a teacher or as a guardian, so it matches nothing you hold.

id joins against the read services — that is the whole value

Type id is the same as
student id on StudentDto
employee id on EmployeeDto
guardian id on GuardianDto

So the join is direct, with no translation:

$mine = MyAppRecord::query()->where('student_ulid', $viewer->id)->get();

Note that those identifiers come from different tables, deliberately: a student is identified by their enrolment row, a guardian by their user record. Don't try to derive the identifier yourself from anything else — read it here.

type is the identity they entered as, not everything they are

One human can be a teacher at the school and the parent of a pupil in it. They enter as one, and this tells you which — the same identity the audience guard used when it decided to serve the page. An app deciding by "is this person a guardian somewhere?" will show a teacher the parent view.

null is not an error

It means nobody is signed in, or the viewer is none of the three types — a platform operator opening your page is exactly that. Render a sensible empty state, not an error.

None of it is read from the request

The identity is resolved server-side from the session context. Never accept a user id from the front end or a query string: a viewer the caller can name is a viewer the caller can forge.

The account type is one thing; what the school granted is another

This is the most-confused point in the integration, so it is said outright. Two people can look the same in front of your screen: an administrator, and somebody granted permissions under the title "HR manager". The first is an account type — the platform's word, meaning the same thing at every school. The second is a permission group this school created in its roles editor, named as it pleased, holding whatever permissions it chose — and the next school calls the same job something else, or does not have it.

Account type Permission group / permission
Decided by The platform — a closed vocabulary The school — invented and named by it
Values tenant_admin (runs the entity), administrator, teacher, student, guardian "HR manager", "Attendance supervisor"… unbounded
How many per person One — a member of staff is a teacher OR an administrator, and either is an employee already Any number, on top of the account type
Where you read it $viewer->accountType $viewer->groups for display, $viewer->can() for the decision
What you build on it The screen — family portal versus staff surface, and the audiences[] declaration The action — may they see this button, may they save this

A group is neither an account nor an account type

A person signs in with one account. A permission group creates no account and no account type; it adds permissions on top of what they already hold. The accountant signs in with her own account, her account type is administrator like any other administrator, and the group added what it added. So do not ask «which account did they sign in with?» — ask «why do they hold this permission?», and the table below answers it.

That is why the DTO carries two fields rather than one, each with its own question:

$viewer->type $viewer->accountType
Answers The identity they entered as The fixed account type
Values employee · student · guardian tenant_admin · administrator · teacher · employee · student · guardian
Used for The big split: family portal or staff surface (isStaff() / isPortal()) Which staff surface, and the audiences[] declaration

With real people:

Person type accountType
The head of the school employee tenant_admin
An administrator employee administrator
An administrator in the "Accounting" group employee administrator — the group does not change it
A teacher employee teacher
Staff whose type was never recorded employee employee
A student student student
A guardian guardian guardian
  • tenant_admin outranks the rest: whoever runs the entity stays that, whatever else they hold.
  • employee is not a third staff type: it is a legacy row nobody ever typed. Treat them as general staff and show what their permissions allow, not as a special class.
  • And the role they entered by is a third thing again: the context page offers one card per role the person holds in the entity — a permission group is a role row, so it is a card like any other — and they press one, which the platform then prints under their name for the whole session. Read it in $viewer->activeRole; see «The door they came in by» below, and do not confuse it with the account type: the first changes between two sessions of the same person, the second does not.

The permission, not the role's name — the functions that tell them apart

The problem these exist for: an app that branches on "administrator" shows the management screens to everyone whose account type is administrator — including the accountant the school put in a group it named "Accounting" and handed one permission. And the app cannot branch on "Accounting", because the next school calls it "Finance" or does not have it. The only answer that is stable across schools is the permission, so ViewerDto now carries the permissions of your app this person holds in this entity — read from the tables, not the session, so it answers the same on the web, on mobile and inside a queued job — together with the source of each one:

Function Answers
$viewer->permissions Your app's permissions they hold (<feature>.<resource>.<action>)
$viewer->can('x.y.view') / canAny([...]) / canAll([...]) Do they hold it — what every button and inner screen is built on
$viewer->accessLevel() full (runs the entity) · granted (holds something of your app) · none
$viewer->isTenantAdmin() / isStaff() / isPortal() / hasGroups() Who they are, with no string comparison
$viewer->permissionSources Map: permission → the roles carrying it (administrator base role, group.accounting permission group, direct grant)
$viewer->grantedBy('x.y.view') Why they hold it — which roles
$viewer->grantedByBaseRole('x.y.view') Held because they are an administrator/teacher (the base-role grant from rbac_permissions)
$viewer->grantedByGroupOnly('x.y.view') Held only because the school gave it to their group, or to them directly
$viewer->groupOnlyPermissions() What this school added on top of the account type
ViewerDto::isBaseRole($key) / isGroupRole($key) Is this key a platform role or a school group

Group keys: a permission group's key is group.<slug>. It used to be administrator.<slug>, which collided with the ACCOUNT TYPE administrator — a person came back as an administrator whose group was also an administrator, and no reader of roles could say which sense was meant. Existing groups were renamed and isGroupRole() accepts both spellings, so ask the helper rather than comparing the prefix yourself.

How to design your screen on that — "the administrator sees their profile, Accounting sees management":

  1. Declare your permissions in rbac_permissions and give the base role the least it needs; what you want in the school's hands, do not grant to a base role:
    "rbac_permissions": {
      "administrator": ["okta_hr.profile.view"],
      "tenant-admin":  ["okta_hr.profile.view", "okta_hr.management.view"]
    }
    
    The school opens its roles editor and puts okta_hr.management.view into a group called "Accounting" — or "Finance" — under the title you wrote in rbac_labels.
  2. Check the permission, not the role:
    $viewer = app(GetCurrentViewer::class)();
    
    if ($viewer->isPortal()) {
        return view('okta-hr::family');                       // the screen: account type
    }
    
    $showProfile    = $viewer->can('okta_hr.profile.view');    // the administrator (base grant)
    $showManagement = $viewer->can('okta_hr.management.view'); // "Accounting", "Finance", or the tenant admin
    
    if ($viewer->accessLevel() === 'none') {
        return view('okta-hr::no-access');
    }
    
    The accountant — an administrator too — sees their profile and management: the first because they are an administrator, the second because their school said so. If you want a view of what the school added alone, groupOnlyPermissions() gives you that; but never hide what a person rightly holds because its source is the base role.
  3. Never compare $viewer->groups[*]['key'], ['title'] or $viewer->roles against text. The scanner will not catch all of them; the second school will.

auth()->user()->can() depends on the guard: it answers false on any request with no user bound to one — which is any mobile route without app.installation. With the middleware it does answer, but $viewer->can() stays the one to ask: it answers the same on the web, on mobile and in a queued job, and does not turn on a guard your app does not control.

If permissions and permissionSources come back empty for somebody who plainly holds them — especially if auth()->user()->can() says yes while $viewer->can() says no — ask $viewer->hasAppContext() before touching the grants.

The list is filtered to your app, so if the request did not carry the app context there is no «your app» and the list is empty by construction, whatever the person holds — indistinguishable from somebody who holds nothing, which is exactly what kept sending people to inspect grants that were fine. accessLevel() === 'none' is then not a verdict about the person: nothing was asked about them.

hasAppContext() false ⇒ the route carries no middleware that builds the context — and which middleware that is differs by surface; see the table below. True with an empty list ⇒ it really is the grants: ask the platform team for php artisan partners:viewer-permissions <slug> --user=<identifier> --tenant=<id>.

Which middleware builds the context — web is one thing, tokens another

module.context:<slug> is web-only. It reads the entity from Tenant::current() (or the portal's entity from the session), and a token call carries no session and therefore no entity — so it answers 403 no_tenant_context before your route is reached. Putting it on a mobile route group stops every call your mini-app makes. (This passage used to say «put it on the page route and on everything Livewire serves from it» without saying it was web-only — which is exactly what one team did, and it took their app down.)

Surface What authenticates Middleware
Your module's web pages (Livewire) The user's session module.context:<slug> — on the page route and on everything Livewire serves from it
Your endpoints called by your mini-app on mobile The user's token (Sanctum) app.installation
Your own server, off-platform An installation bearer token app.installation
// The mobile route group — no module.context here
Route::middleware(['api', 'app.installation'])
    ->prefix('api/__MODULE_SLUG__')
    ->group(function () {
        Route::get('/me', [MeController::class, 'show']);
    });

No header for you to write: on every call your mini-app makes, the host sends X-Module-Slug, X-Okta-Tenant-Id and X-Okta-Role-Id beside the user's token, and app.installation resolves the installation from (entity, slug) server-side, re-checks the app card's gate for the active role, and sets the ModuleContext. GetCurrentViewer then works in full on mobile: accountType, permissions, can(), hasAppContext() and activeRole.

The token that reaches you is a USER token, not an installation token. An installation token is a tenant-wide server credential and never ships to a phone. So identity comes from the user and authority from the installation — your mini-app never gains more than your app was granted.

So, in practice:

  • Never branch on a group's key or title. group.hr and "HR manager" are one school's words. An app that writes if ($viewer->groups[0]['title'] === 'HR manager') works at one school and goes quiet at the next. The group reaches you so you can show it beside the name, nothing more.
  • What a group buys is answered by can() alone. The school put __MODULE_LOWER__.reports.view into "HR manager"? Ask about the permission, not the group — and the answer keeps working at the school that gave the same permission to a group called "Deputy".
  • The account type is one and fixed, so build the screen on it and nothing else. A member of staff is a teacher or an administrator — not both — and whoever runs the entity outranks either; employee arrives only for a legacy row nobody ever typed, so treat it as staff without a type rather than as a third type.
$viewer = app(GetCurrentViewer::class)();

// The screen: account type
if ($viewer->accountType === 'guardian') {
    return view('__MODULE_SLUG__::family');
}

// The action: the permission — not the group, not the role
$canApprove = $viewer->can('__MODULE_LOWER__.leave_requests.decide');

// Why they hold it — when it matters: a base grant, or something the school added
$viewer->grantedByGroupOnly('__MODULE_LOWER__.leave_requests.decide');

// Display only: what the school calls them
$badge = $viewer->groups[0]['title'] ?? null; // "HR manager"

The door they came in by — activeRole

The three fields above are about the person: roles is everything they hold in the entity, permissions is everything those roles carry. This one is about the session: the single role they pressed on the context page, which the platform then prints under their name — «Administrator» one time, «Accounting» the next, because a permission group is a role row and therefore a door like any other.

Field / function Answers
$viewer->activeRole ['key' => 'group.accounting', 'name' => 'group.accounting', 'title' => 'Accounting'] — or null
$viewer->enteredAs() The door's key, in the same shape as roles[] in your audiences, so it compares directly
$viewer->enteredAsTitle() Its label as the platform prints it — for display only, never compare it
$viewer->enteredAsBaseRole() / enteredAsGroup() Entered by a platform role, or by a group the school invented
$viewer->activePermissions The slice of permissions that door carries — or null
$viewer->activeCan('x.y.view') / activeCanAny([...]) Does this door carry it
$viewer = app(GetCurrentViewer::class)();

// The surface follows the door: the administrator who is also in «Accounting»
// gets the staff screen when they entered as an administrator, and the finance
// screen when they entered as the accountant.
if ($viewer->activeCan('okta_hr.management.view')) {
    return view('okta-hr::management');
}

return view('okta-hr::staff');

Three rules govern it, and each was written because its opposite breaks something:

  • It segments; it does not authorise. Entering by a base role takes nothing away: the platform's own gate does not narrow to the door, so can() still answers on everything the person holds here. Use activeCan() to decide what is shown and keep can() for what may be done — an app that refuses an action because the door does not carry it disagrees with the platform about the same person in the same second, and refuses on the web what it allows on the phone.
  • null means «no door», not «no role». Where no door is recorded on the request both fields are null, and activeCan() falls back to can() — so a screen built on it does not go blank. Do not write ?? false around it, and never read null as a refusal.
  • Never accept it from the client. Do not send «the selected role» in a request body or a header, and do not keep it in the browser: the platform reads it from server-side context only, and a role the caller can name is a role the caller can forge.

The door reaches you on mobile too. The phone picks a role at sign-in and sends it on every call (X-Okta-Role-Id), and app.installation verifies it is a role the caller holds in this entity — the app card's gate already turns on it — before handing it to the viewer. So it is not a claim from the client: a role they name but do not hold never gets through, and one they do hold can only narrow what you draw. can() is unchanged either way.

This section used to say activeRole was null on mobile and that «mobile stays on the union». The role was being verified and then discarded; it now arrives, so the one surface with no can() is no longer also the one with no door.

And impersonation («browse on behalf of»): activeRole is the impersonated person's — the platform rebuilds the whole context for them, so the session is theirs and so is the door. But note: in impersonation — and when signing into the sandbox — nobody pressed a card, so the platform takes the first of their roles. It is a role they genuinely hold, but it is not «what they chose»; one more reason not to make activeCan() a gate on actions. The difference between two doors must stay a difference of display.

A ready-made prompt for your assistant (Claude / Cursor)

Paste it as-is into the AI assistant inside your app's repository, to review the code against this rule and fix what breaks it:

Review our app against the Okta platform rule "the account type is one thing,
what the school granted is another", and fix whatever breaks it:

Two people can look the same in front of the screen: an administrator, and
somebody granted permissions under the title "HR manager". The first is an
account type — the platform's word, meaning the same thing at every school.
The second is an administrator group this school created and named as it
pleased; the next school calls the same job something else or does not have it.

We read both from GetCurrentViewer:
- $viewer->accountType — the platform's fixed vocabulary, one per person:
  tenant_admin (runs the entity, outranks the rest) | administrator | teacher |
  student | guardian — and employee only for a legacy row nobody ever typed.
  A member of staff is a teacher OR an administrator, never both.
- $viewer->groups — [{key, title}] the administrator groups the school made,
  with its titles. For display beside the name only.

Required:
1. Screens are built on accountType alone: any "family portal or staff
   surface" branch, and any audiences[] declaration, reads it from here — not
   from the raw $viewer->roles and not from a role name.
2. Actions are built on the permission alone: may they see this button, may
   they save → $viewer->can('__MODULE_LOWER__.<resource>.<action>') (answers
   the same on web and mobile; auth()->user()->can() is false without a
   session). Never "are they an administrator", never "are they in group X".
   Helpers: canAny/canAll, accessLevel() (full|granted|none),
   isTenantAdmin()/isStaff()/isPortal(), and
   grantedByGroupOnly()/grantedByBaseRole()/permissionSources when WHY they
   hold it matters — the base-role grant from rbac_permissions, or something
   this school added to a group. Grant the base role the least it needs and
   leave what you want in the school's hands to its groups.
3. Never branch on a group's key or title. Search the code for any comparison
   against text such as 'HR manager' or 'group.hr' or
   $viewer->groups[...]['key'|'title'] inside an if/match, and replace it with
   can() on the permission it actually means — and declare that permission in
   manifest.json → rbac_permissions with feature = __MODULE_LOWER__, and its
   title in rbac_labels.
4. Do not treat employee as a third type: it is staff with no recorded type.
   Show them the general surface and whatever their permissions allow.
5. Never assume a permission holder is an administrator, nor that an
   administrator holds a permission: show what the permissions allow and hide
   the rest instead of answering 403.
6. If you want the surface to follow the role the person entered by («
   Administrator» one session, «Accounting» the next), use
   $viewer->activeCan()/enteredAs()/enteredAsGroup() for DISPLAY only and keep
   can() for every refusal. activeRole is null on token requests and
   activeCan() falls back to can() there — so do not write ?? false around it
   and never read null as a refusal.

The correct shape:
    $viewer = app(GetCurrentViewer::class)();
    if ($viewer->accountType === 'guardian') return view('__MODULE_SLUG__::family'); // screen
    $canApprove = auth()->user()->can('__MODULE_LOWER__.leave_requests.decide');   // action
    $badge = $viewer->groups[0]['title'] ?? null;                                  // display only

Reference: the developer guide, "Platform accounts" chapter → "The account type
is one thing; what the school granted is another" — read it with
read_guide/search_guide before editing, then run scan_code and
validate_manifest on what you changed.

What the declaration does to your app's visibility

The declaration governs more than routes — it governs whether your app appears in the launcher at all:

  • Declared no audiences? Your tile shows to everyone in the entity, as it always has.
  • Declared audiences and the user matches one? The tile shows, linking to that audience's route.
  • Declared audiences and the user matches none? No tile at all.

The third case is deliberate: a tile opening a page the guard will refuse is worse than no tile, because the user reads it as a permissions failure in the platform rather than an app that was never theirs. So if your app disappears for an account type you expected to see it, the fault is in your declaration, not in the entity's install.

The second axis: whoever holds one of your permissions gets in

The account type is not the only axis, and you must not build on it alone. The role names you write in roles[] are your guess at what schools call things; an entity invents its own roles ("Attendance supervisor", "Deputy for affairs") and does not ask you. So an app declaring roles: ["tenant-admin", "teacher"] vanishes for an employee the school gave all twelve of its permissions — because the two axes never met.

So: whoever holds any permission from your app's rbac_permissions reaches your general surface — a tile in the launcher, an allow from the guard, and a card in the mobile catalog. The entity granting the permission is the entity saying that employee uses your app.

And the two axes stay separate, which is the condition:

Axis Decided by Fixed? What it governs
audiences[] You, in the manifest Yes A screen per account type — which route each one opens
rbac_permissions The entity, in its roles editor No What may be done inside your app

A permission opens menu.route (your general surface) and never another audience's screen: someone you did not declare for school-app.admin does not get in, however many of your permissions they hold. Two cases, and only two, open nothing:

  • A portal audience owns menu.route (student or guardian): a staff permission is never a way onto the family screen, whatever the school granted.
  • Your staff screens are genuinely split — two or more different routes — and menu.route is one of them: getting in would mean landing on a screen built for somebody else.

Every staff audience pointing at the same route is not a split. An app declaring one audience (tenant-admin → app.dashboard) where menu.route is that very route has one staff screen; the audience says who meets it by default, not that it belongs to that role alone, and a permission holder reaches it. That is the shape of most apps, and the case where this rule works without you changing anything in your manifest.

What this means for you in practice:

  • Guard your screens with can(), not by assuming a role. auth()->user()->can('__MODULE_LOWER__.orders.view') is the right question; "is this user a tenant-admin?" is one that answers wrongly at the first school that names its roles differently than you expected.
  • Declare rbac_permissions for every real action in your app (see Role permissions inside your app) — they are now the way in, not just a description of what may be done.
  • Keep one staff surface that receives whoever entered by permission, then branch from it with can() — either a menu.route no audience owns, or a single staff screen every one of your audiences points at. An app that splits its staff across different routes shuts its door on every role you did not foresee.

Today the guard logs; it does not yet refuse

PARTNER_AUDIENCE_GUARD_MODE currently defaults to log: the guard records the violation and serves the page. Which means a wrong declaration — a portal key inside roles[], or an invented role without custom — looks like it works today, and stops working entirely the moment that value is flipped to enforce.

So do not test your declaration against today's behaviour. Test it under enforcement:

config(['partners.audience_guard.mode' => 'enforce']);

And the rule there is deny by default inside a segmented app: the moment you declare audiences, every route no audience covers is refused. A page you forgot to declare does not stay open — it closes. Inventory your routes before you declare your first audience.

Note: hiding the tile in the launcher works now, without waiting for the mode to flip, because it is display filtering rather than a guard. So you may well see your app hidden from a user who can still open its route by typing it — that gap is precisely the difference between log and enforce.

Companies and complexes: the tree and its schools' data

Scopes answer «which data»; the previous chapter answers «who is sitting there». A third question reaches neither: what kind of place is your app running in, and what sits above and beneath it.

A tenant is not always a school. An education company owns complexes, a complex holds schools — and the same app may be installed at any of the three levels. On a school your app shows that school's pupils; at the company's head office it has no pupils of its own, and its job is to move across the schools beneath it. Before these endpoints /whoami gave an app a numeric tenant id and nothing else: a head-office app could not name its schools, and a school app could not tell it belonged to a complex at all.

In short — can a complex's app read its schools' data? Yes, straight from the mini-app. Name the schools on any data read: ?entities=<ulid>,<ulid> (an envelope keyed by school) or ?entity=<ulid> (one school's answer, as is). Three things are needed: your app holds entity.hierarchy.read, the data scope is granted on the complex or on the school — either is enough — and the person using the app holds tenants.children.view_members in the complex. The details are in «Reading the schools' data from a complex» below, and the full Dart example in the native mode chapter.

The tree has no fixed depth

This is the first thing apps get wrong. A school may sit under a complex under a company; under a complex that is itself the root with no company above; directly under a company with no complex between; or alone under nobody. One company may hold both shapes at once — a direct school and one inside a complex.

company ─┬─ complex ─┬─ school A
         │           └─ school B
         └─ school C            ← direct, no complex

standalone complex ─── school D   ← no company above

school E                          ← nobody above

So never write «the second ancestor is the company». Read the chain as it comes and check type.

Where am I? — GetCurrentEntity (no scope)

use App\Services\PartnerApi\Entity\Hierarchy\GetCurrentEntity;

$here = app(GetCurrentEntity::class)();

$here->id;           // the entity's ULID — the same `entityId` ViewerDto reports
$here->type;         // school | complex | education_company | university | …
$here->isContainer;  // may this type hold other entities?
$here->parentId;     // ULID of the entity directly above, or null
$here->students;     // held by THIS entity itself — never the tree's total

Unscoped on purpose, beside /whoami and the clock and for the same reason: the entity's own name and type are the frame your data is read in, not data a grant protects. The parent's name and the rest of the tree belong to other tenants and sit behind the scope below.

Start here. type decides which screen your app draws: isContainer true ⇒ the head-office screen (a list of entities); false ⇒ the school screen (pupils).

The one scope: entity.hierarchy.read

Unlocks the tree reads below, up and down, and means one thing: «this app may see the shape of the tree it is in» — who is beneath it, each one's profile, and who is above. A school that is a company's customer did not agree to be enumerated by every app the company installs on itself — so the entity's administrator grants this explicitly. There is no .write: the tree is drawn by the platform and the company's administrators, and an app that could re-parent a school is an app that could move pupils between customers.

It is also a condition for reading the children's data (below): an app that was not allowed to see the tree does not read its schools, whatever ULIDs it holds.

Down: who is beneath me? — ListChildEntities

use App\Services\PartnerApi\Entity\Hierarchy\ListChildEntities;

$direct = app(ListChildEntities::class)();                    // next level only
$all    = app(ListChildEntities::class)(descendants: true);   // every level

foreach ($all as $entity) {
    $entity->name;       // "Elite School"
    $entity->type;       // school
    $entity->parentId;   // its parent's ULID — rebuild the tree from the flat list
    $entity->students;   // its own pupils
    $entity->children;   // how many entities it holds (a complex)
    $entity->installed;  // whether your app has its own installation there
    $entity->access;     // ['installed' => …, 'scopes' => [...]] ← what can be read there
}

installed and access are what make the list actionable.

  • installed — whether your app has its own installation on that entity. That is still the only way to write into it, with the scopes its administrators granted.
  • access.scopes — the read scopes your app reads that entity with: what it granted your installation there, if any, plus the read grant your own entity gave, carried down. Either is enough — so a school that never installed your app is read with the complex's grant.

The list also says whether the person using the app right now may read the children: can_read_children on the /entity/children response, and app(CanReadChildEntities::class)() in embedded code. Read it before you draw a «download the schools' data» button, and do not draw it when false.

(Installing on many children at once is done by the company's administrator in okta-web: Child entities → Install app — each child gets its own installation and its own grant.)

Up: whom do I belong to? — GetEntityAncestors

use App\Services\PartnerApi\Entity\Hierarchy\GetEntityAncestors;

$chain = app(GetEntityAncestors::class)();   // nearest first; empty at the root

// a school under a complex under a company:
$chain[0]->type;  // complex           ← the complex
$chain[1]->type;  // education_company ← the company

// a school directly under a company:
$chain[0]->type;  // education_company — and there is no [1]

// a school alone:
$chain === [];

This decides real things in an app: whose letterhead goes on a report, whether a fee is set here or inherited from above, whether a notice came from the school or from head office. Search the chain for the type you want rather than relying on its position:

$company = collect($chain)->firstWhere('type', 'education_company');  // or null
$complex = collect($chain)->firstWhere('type', 'complex');            // or null

One school's profile — GET /entity/children/{id}

The list is a map; this is what you draw on a school's card:

{
  "id": "01HN…A", "name": "Elite School", "type": "school", "parent_id": "01HN…X",
  "status": "active", "students": 412, "employees": 38, "children": 0,
  "grades": 6, "sections": 18,
  "logo": "/api/apps/entity/children/01HN…A/logo",
  "phone": "0112345678", "email": "info@…", "website": null, "address": "Al Malqa, Riyadh",
  "country": { "code": "SA", "name": "Saudi Arabia" },
  "timezone": "Asia/Riyadh",
  "stage": { "id": 3, "country_id": 1, "name_ar": "الابتدائية", "name_en": "Primary", "order": 1, "is_active": true },
  "installed": false,
  "access": { "installed": false, "scopes": ["education.students.read", "entity.hierarchy.read"] },
  "can_read_children": true
}
  • The logo is a path, not a URL, like a pupil's photograph: a mini-app cannot fetch a URL itself, so show it with Okta.remoteImage(school['logo'], 48). null when there is no logo.
  • country.code is the code /countries answers with, and stage is the very object /countries/education-levels lists — match by id, not name.
  • Counts are the school's own, like everywhere on this surface — never a tree's sum.
  • A school not beneath you, and a ULID that names nothing: one 404 — entity_not_found.

Reading the schools' data from a complex — ?entities= and ?entity=

Your app is open in a complex, and the pupils are in its schools. The data routes themselves answer for the schools beneath when you name them — no server code, straight from the mini-app:

final r = await Okta.getQuery('/api/apps/education/students', {
  'entities': '$schoolA,$schoolB',   // up to 20 schools per call
  'per_page': '100',
});

The answer is wrapped by school, and each key holds exactly the answer the route gives that school — read it with the same code you read your own entity's answer with:

{
  "entities": {
    "01HN…A": { "data": [ … ], "total": 412, "per_page": 100, "current_page": 1, "last_page": 5 },
    "01HN…B": { "data": [ … ], "total": 88, "per_page": 100, "current_page": 1, "last_page": 1 },
    "01HN…C": { "error": "missing_scope", "status": 403, "scope": "education.guardians.read", "message": "…" }
  }
}

Which grant reads a school? Either of two, and either is enough: what the school granted your installation there, or the read grant your entity (the complex or company) gave, carried down. So a school that never installed your app, removed it, or did not grant the scope, is read as long as the complex granted it. Two limits:

  • Only scopes ending .read travel down. Writing into a school still takes that school's own grant: ActInChildEntity for a person operating the complex's managed copy (below), RunAsChildEntity in a job, or that school's own token for your server.
  • They travel for a person. Whoever is using the app must hold the unified-registry permission (tenants.children.view_members) in the complex — else 403 children_not_readable. Your server with an installation token never reads this way (403 entities_need_a_person): it reads each school with that school's own token, as before.

How the answer behaves:

  • Pagination is per school: page and per_page apply to each key. For a school with pages left, ask it alone: ?entities=01HN…A&page=2.
  • A refused school is one key, not the response: its key carries error and status (and scope when a scope is missing), the rest arrives, and the response is 200.
  • entity_not_found for a school not beneath you and for a ULID that names nothing — the same answer on purpose, so the envelope cannot be used to learn what exists.
  • Your own entity may be named in the list, and its data arrives under its key.
  • A filter that belongs to one school (section_id, say) is sent as-is to every school, and the others answer an empty page, not an error.

?entity=<ulid> for one school: the answer is that school's answer as is — no envelope, same status and headers. A pupil's photograph is only read this way, because a file cannot sit inside an envelope:

Okta.remoteImage('/api/apps/education/students/$id/photo?entity=$school', 64);

Refused by name, never ignored (ignoring it would answer with the complex's own data looking like success):

Code Status When
entities_read_only 422 any write — this is a download
entities_not_supported 422 a route that answers your own entity: whoami, the clock, the tree, payments, notifications — or entities on a file route
too_many_entities 422 more than 20
entity_and_entities / invalid_entities 422 both at once, or an empty list
missing_scope (entity.hierarchy.read) 403 your app does not hold the tree scope
children_not_readable 403 the person does not hold tenants.children.view_members, or came in through a narrowed door (a guardian's delegate)
entities_need_a_person 403 an installation token (a server) or a device with no person

It works on every read of an entity's data: education/* (pupils, photos, face templates, guardians, subjects, curriculum, grades, sections, years, terms), employees/directory, tool-keys/* and reports/*. No minContract bump: the parameter is part of the path, and Okta.get/Okta.getQuery carry it on every host.

Acting inside a child — RunAsChildEntity (embedded only)

The embedded counterpart of ?entities=: it steps into a child and runs your code there, so every PartnerApi service inside behaves as if your app were installed on that school. The same rule: inside the school your app holds what the school granted its installation there, plus the read grant from your entity carried down when the one asking is a person holding tenants.children.view_members. Writes take the school's own grant only.

use App\Services\PartnerApi\Entity\Hierarchy\ListChildEntities;
use App\Services\PartnerApi\Entity\Hierarchy\RunAsChildEntity;
use App\Services\PartnerApi\Education\Students\ListStudents;

$children = app(ListChildEntities::class)(descendants: true);

foreach ($children as $school) {
    if ($school->type !== 'school' || ! in_array('education.students.read', $school->access['scopes'], true)) {
        continue;   // not a school, or no grant reads its pupils
    }

    $students = app(RunAsChildEntity::class)($school->id, function () {
        // In here you ARE "an app on this school": Tenant::current() is the
        // school, the scopes are its own grant plus your entity's reads.
        return app(ListStudents::class)(perPage: 100)->data;
    });
}

Checked, in order: (1) you hold entity.hierarchy.read; (2) the target is a descendant of your entity at any depth — a sibling, a parent or a stranger is refused as «not found», never «forbidden», so the error cannot be used to probe which tenants exist; (3) the call is not made from inside a complex's act — else ChildActRefusedException (already_acting); (4) the asking context was not narrowed on purpose — a screen pinned to classrooms or grades, a guardian's delegate, or an installation token with a signed-in person on the same request — else ChildActRefusedException (narrowed); (5) there is a grant to act under: the same app's active installation on the target, or — for a person holding the permission — the read grant from your entity, else MissingScopeException with the key entity.child_installation. The current tenant and context are restored unconditionally after the closure, even when it throws — your request must not go on running as the school on the next line.

With no person, your entity's grant does not travel down. A scheduled task, a queue, a server: nobody is asking, so inside the school your app holds what the school granted, only, and a school that never installed it is refused as before. With a person holding the permission, your entity's reads travel down — never its writes.

Not on a request path. It asks nothing about the person where it matters: a school that installed your app is entered under that school's own grant — writes included — whoever is asking, even somebody the complex never let into its schools. The person check above decides only whether your entity's reads are added on top of that grant. Keep it for scheduled tasks, queues and servers. On a request path a person reads the schools with ?entities= (or the tree reads) and writes with ActInChildEntity — «Your app works inside a complex's schools» below.

External apps: one token per entity

An external app's server already holds one installation token per tenant: it reads the tree from the entity it is in, then reaches a child with that child's token on the same API, and installed tells it whether such a token can exist. ?entities= is not for it: a token has no person behind it, so it is answered 403 entities_need_a_person. The surfaces of your app that a user opens (a mini-app, a web page) use ?entities= like any other.

Endpoint Scope Answers
GET /entity (none) where am I: type, is_container, parent_id
GET /entity/children?descendants=1 entity.hierarchy.read who is beneath me, with installed and access per entity, and can_read_children
GET /entity/children/{id} entity.hierarchy.read one child's profile: logo, contact, country, stage, sizes, access
GET /entity/children/{id}/logo entity.hierarchy.read its logo — as a file, 404 when there is none
GET /entity/ancestors entity.hierarchy.read whom I belong to, nearest first
any data read with ?entities= / ?entity= the route's scope + entity.hierarchy.read the children's data — for a person holding tenants.children.view_members

How an app declares «supports companies and complexes»

Working on a container needs no manifest flag. What makes an app work there is three things in code: (1) check type/isContainer on open and draw the matching screen; (2) request entity.hierarchy.read in your scopes whenever you need the tree; (3) assume no depth. An app that does (1) alone keeps working on a school exactly as before, and at head office shows a screen that says what it is — instead of showing «no pupils» and looking broken.

The two declarations in this area answer different questions — not whether your app works on a container, but whether a container may put it in its schools so they cannot remove it (the next section), and whether a container installing it installs it in its schools too (the one after).

When the head-office screen is another app rather than a branch in the same screen, do not ship it to every school: declare a row for the account type narrowed by entity_types (chapter «Platform accounts», section «Entity type»), and the platform picks the package by the entity's type — the complex's package never reaches a school's phone.

A complex installs your app in its schools — hierarchy.managed_install

A complex (or an education company, or a university) can already install any store app in its schools from its «sub-entity apps» page — as each school's own copy, which the school removes whenever it likes. For some apps that is the wrong shape: the complex's attendance, its HR, its transport are decisions the complex takes for all its schools, and a school quietly removing one leaves a hole in the complex's picture that nobody notices until a report comes back short.

One flag in the manifest opts your app into the other shape:

{ "hierarchy": { "managed_install": true } }

With it on:

Who What happens
The complex Installs the app for itself first — its own copy is what manages the schools' copies. Then chooses, on the «sub-entity apps» page, which entities beneath it get it, at any depth (a company reaches its complexes and their schools alike), and whether every entity that joins it later — created under it or moved under it — gets it automatically.
Each chosen school Gets the app, and keeps everything about it: its settings, its notifications (a notification nobody at the school has configured follows the complex's setting), which of its staff may use it (the complex's operators also act inside it — «Your app works inside a complex's schools» below). It loses one thing — the uninstall button — and reads «installed by » in its place. The store API refuses the uninstall by name (403, error: managed_by_ancestor).
A school that already had your app Keeps it as it is — taking it over widens nothing; the complex becomes the one that manages it.
Removal The complex removes the app from one school on the same page, or from all of them at once by uninstalling its own copy. Either way it undoes what it did: a copy it installed is uninstalled; a copy the school had installed before is given back — still installed, unlocked.

Three things to know before you turn it on:

  • The complex approves every scope your app requests on each school's behalf — as the regular bulk install already does; taking over a copy the school installed itself widens nothing. And as on every install, each publish of yours sets every copy's scopes to what your manifest declares — the review is the platform's consent point. So request what the app needs and nothing more: on a managed install nobody at the school ticks the list.
  • One container at a time. A school's copy is held by one container; a company that already manages it there is not overridden by a complex beneath it — the complex's page says who holds it.
  • The flag governs new placements only. Turning it off in a later version stops complexes placing the app managed from then on, and stops the automatic placement in new schools; copies already placed stay managed until the complex removes them. Your publish never unlocks a school.

It is saved with the version: the «Integration» tab of the version editor, set_managed_install over MCP, or sync_from_manifest from your repo — keep the repo and the version in step, or the next sync writes the repo's value back. validate_manifest refuses a "1" or a 1: okta-web honours a literal true and nothing else.

Nothing in your code changes: a managed copy is an ordinary installation, with its own token, its own settings and the scopes approved for it.

A complex's install covers its schools — hierarchy.reach

The managed install above leaves the complex to choose the schools. For an app every school of the complex runs without exception — attendance, transport, dismissal — that choice is a step somebody forgets: the complex installs the app for itself, assumes it is now in its schools, and it is not. So you, as the developer, answer one question once: when a complex installs your app, what does the install cover?

reach What happens when a complex (an education company, a university) installs it
"container" — the complex only It is installed in the complex alone and serves the complex. This is what happens for every app that does not name the key, so saying it and leaving it out read the same.
"container_and_children" — the complex and its schools It is installed in the complex and, with it, in every entity beneath it at any depth as a managed copy (as in the previous section) the school cannot remove — with nobody choosing anything. Every entity that joins the complex later (created under it, or moved under it together with its schools) gets it automatically. The complex removing its own copy removes it from its schools.
{ "hierarchy": { "reach": "container_and_children" } }
  • It carries the managed install with it: "container_and_children" places managed copies, so it means managed_install: true whether you write it or not — and the complex can still remove it from one particular school on its sub-entity apps page, as in the previous section. "managed_install": false beside it is a contradiction validate_manifest and okta-web refuse by name.
  • Whoever presses «install» is asked about the schools too: the store dialog tells the complex, before the button, that the app will be installed in its entities and how many there are, and it refuses the install from a person without the «install apps in sub-entities» permission (or on a plan without it) — not a silent install on the complex alone. The store API refuses it with 403, error: reach_not_allowed.
  • A school that installed it before the complex keeps it as it is, and the complex becomes the one who manages it; a copy a container higher up manages (a company above the complex) is left alone. A company installing it reaches its complexes and their schools together, and a complex it is placed in does not claim its schools first.
  • The complex approves every scope your app requests on each school's behalf — and nobody at the school ticks the list. Request only what the app needs.
  • The choice governs installs from now on. A complex that installed an earlier version is not cascaded when you publish: your publish never installs anything in a school its complex did not decide on. That complex can place it in all its schools from its sub-entity apps page. The reverse too: going back to "container" stops new installs, and existing copies stay until the complex removes them.
  • Your app knows where it runs: the copy in a school is an ordinary install with the school's token, and the complex's copy reads its schools with ?entities= as at the start of this chapter — and writes in them through ActInChildEntity (next section).

It is saved with the version: the «Integration» tab of the version editor (the «When a complex installs your app, what does the install cover?» choice), set_install_reach over MCP, or sync_from_manifest from your repo — keep the repo and the version in step.

Your app works inside a complex's schools — ActInChildEntity

A complex that runs one gate for all its schools — arrivals at its door, dismissal, the bus — writes records about pupils who are enrolled in its schools, not in the complex. ?entities= reads those schools and never writes; RunAsChildEntity enters a school without asking anything about the person. ActInChildEntity is the path for a person operating the complex's copy of your app: the platform asks for their authority at the complex, then runs your code inside the pupil's school under that school's own copy of your app — so the record, and the notice to the pupil's parents, land in the school, with the school's settings.

In short: embedded apps only, in process — there is no HTTP route for it. The person holds two keys at the complex: tenants.children.operate_apps and a permission of your app. Only schools whose copy of your app the complex's own installation manages (hierarchy.managed_install / hierarchy.reach) open. The school a record lands in is derived from the pupil on the server, never taken from the request.

Who it is for

  • An embedded app, in process: services under App\Services\PartnerApi\…. There is no partner HTTP route for acting inside a school — nothing under /api/apps/*, nothing in the API reference.
  • A mini-app writes through your own endpoints (behind app.installation — chapter «Platform accounts», section «Which middleware builds the context»), and those endpoints call these services. It reads the schools with ?entities= as before, or through your endpoints that call the tree reads below.
  • An external app's server stays on one token per entity: it writes into a school with that school's own token.

Two keys, both held at the complex

  • tenants.children.operate_apps — «Operate apps inside child entities». A platform permission: the global tenant-admin role holds it, and the complex grants it to any other group that runs your app across its schools.
  • A permission of your app for the act — say my_app.arrivals.record — declared in your rbac_permissions or under your prefix. The operate permission is never accepted as "yours": the two keys stay two.

Either one alone is refused: your permission alone is usually what every administrator of the complex holds, and the operate key alone names no act. Both are read in the complex's own team, from the permission tables — not through can() (a platform operator's blanket yes does not count), and not in the school (a tenant-admin created inside the school by «Enter» holds nothing here). And both are narrowed by the role the person came in by — the card picked on the web, the X-Okta-Role-Id that app.installation verified on mobile; a permission granted to the person directly, not through a role, is not narrowed. If your mobile routes run a door of your own instead of app.installation, pass that header's value to VerifyEnteredRole before the first act (App\Services\PartnerApi\Identity\VerifyEnteredRole, __invoke(int $tenantId, ?int $claimedRoleId): void): for a role the person does not hold there it throws AccessDeniedHttpException (a 403, message entered_role_not_assigned), and without the call nothing narrows — the person acts with every role they hold at the complex.

Your app itself needs entity.hierarchy.read on the complex's copy.

Which schools open

Only a school whose active copy of your app is managed by the complex's own installation — placed or adopted through hierarchy.managed_install or hierarchy.reach (the two sections above). A copy the school installed itself that nobody adopted, a copy a company above the complex manages, a school with no copy, a suspended school: child_not_managed. It is asked again on every call, so a complex that uninstalls its copy, or a school moved out from under it, is closed from the next request.

Inside, your app holds the school's copy's grant and nothing else: no scope of the complex's is added, and there is no fallback to the complex's grant. Name the scopes your code needs in $requiredChildScopes; a school whose copy lacks one is refused school_grant_missing:<scope> before your code runs.

One school — ActInChildEntity

__invoke(string $childEntityUlid, string $appPermission, Closure $callback, array $requiredChildScopes = []): mixed — it returns what your closure returns.

Inside the closure Tenant::current() is the school; the module context is the school's copy (its installation, and the scopes the school approved for it); GetActiveEntityId() and GetCurrentEntity() answer the school; DispatchNotification sends under the school's copy. All of it is restored in finally, even when the closure throws.

The questions, in order — each word is the reason of App\Exceptions\ChildActRefusedException unless the line says otherwise:

  1. Your app holds entity.hierarchy.read — else MissingScopeException.
  2. The context is your app's own active installation at the entity it runs in — else not_the_app_root (a context carried down into a school is not a root).
  3. Not already inside an act — else already_acting: a school never acts on what is beneath it with the complex's authority.
  4. The permission is your app's — else InvalidArgumentException('app_permission_not_declared'); an empty or non-string entry in $requiredChildScopes is InvalidArgumentException('required_scope_invalid'). Bugs in your code, not answers about the person.
  5. That entity is a container — else not_container — and the ULID names a live entity beneath it — else child_entity_not_found, the same answer for a sibling, a parent, a stranger and a ULID that names nothing.
  6. The actor, at the complex: no_actor (nobody — a queued job, an installation token), narrowed (a door narrowed on purpose — a screen pinned to classrooms, a guardian's delegate), missing_permission (not a live member of the complex, or one of the two keys missing through the role they came in by), device_elsewhere (a panel paired to another entity).
  7. The school's copy is managed by the complex's installation — else child_not_managed.
  8. The school's own grant holds every scope in $requiredChildScopes — else school_grant_missing:<scope>.

Steps 1–4 are about your call, and are thrown before any school is looked at. From step 5 on, the platform writes one audit line (app-children-act) per call — per school, not per record — for every act and every refusal: who, through which role, which permission, which school, the outcome. Nothing is remembered between requests: revoking the operate permission or uninstalling the complex's copy takes effect on the next one.

Let the exception through and the answer is one of two: 404 {"error": "child_entity_not_found"}, or 403 {"error": "child_act_refused", "reason": "…"}. To branch on it, catch ChildActRefusedException (the policy scanner allows App\Exceptions\*) and read reason, childEntityUlid and isNotFound().

Many pupils — ActForStudentsInTree

__invoke(array $studentUlids, string $appPermission, Closure $perSchool, array $requiredChildScopes = []): ChildActBatch — your closure receives (string $entityUlid, array $studentUlids).

  • It places each pupil in the entity they are enrolled in — on the server, from their live enrolment — and runs your closure once per school through ActInChildEntity: two hundred records across five schools are five acts.
  • Pupils enrolled at the complex itself run in place, under the complex's own copy, with no switch — and with no two-key check and no audit line: they are your app's own entity, guarded by whatever guards your route. At a school (not a container) the call still works: that school's pupils run in place and everybody else is unplaced.
  • One school refusing never stops the others, and nothing for its pupils is written anywhere — a refused school never turns into a write under the complex.
  • ChildActBatch: results (entity ULID → what your closure returned), refused (entity ULID → the reason), unplaced (pupil ULIDs outside the complex, released, or naming nobody — one bucket on purpose), pupils (entity ULID → the pupils grouped there) and entityOf($studentUlid).
  • Thrown rather than recorded per school: MissingScopeException, not_the_app_root, already_acting and the InvalidArgumentExceptions — they are about your call. An exception from your own closure is yours: it is not a refusal, and it propagates.
use App\Services\PartnerApi\Entity\Hierarchy\ActForStudentsInTree;
use App\Services\PartnerApi\Notifications\DispatchNotification;
use Modules\MyApp\Models\Arrival;

// The gate sends pupil ULIDs — and nothing about their schools.
$pupils = $request->input('students', []);

$batch = app(ActForStudentsInTree::class)(
    $pupils,
    'my_app.arrivals.record',        // your permission, held at the complex beside operate_apps
    function (string $schoolUlid, array $schoolPupils): int {
        // Once per school, inside it: the tenant, your module context and the
        // notification settings are that school's.
        foreach ($schoolPupils as $pupilUlid) {
            Arrival::create([
                'school_ulid' => $schoolUlid,   // where the pupil is enrolled — decided by the platform
                'student_ulid' => $pupilUlid,
                'arrived_at' => now(),
            ]);

            app(DispatchNotification::class)('my-app.arrival.recorded', [
                'recipient' => ['type' => 'parent_of_student', 'id' => $pupilUlid],
            ]);
        }

        return count($schoolPupils);
    },
    ['education.students.read'],     // each school's OWN copy must hold these
);

$batch->results;    // ['01HN…A' => 12, '01HN…B' => 3]
$batch->refused;    // ['01HN…C' => 'child_not_managed'] — nothing was written for those pupils
$batch->unplaced;   // outside the complex, released, or unknown

Reading across the tree — without entering a school

The tree reads run at the complex and never switch. Each one asks the same two keys (your permission here is the read's — say my_app.arrivals.view), and reads each school under that school's copy's grant. That is what separates them from ?entities=, which asks tenants.children.view_members and reads any school beneath with either grant: the tree is the complex and the schools it manages, nothing else.

Call Answers Scope besides entity.hierarchy.read
ListOperableChildEntities(string $appPermission): OperableEntitiesDto The probe: refusal (null, or the word) and entities — the complex first, then its managed schools by name. Each OperableEntityDto has id (ULID), key, name, type, isRoot, scopes (that copy's own grant) —
ListOperableStudents(string $appPermission, int $page = 1, int $perPage = 500, ?string $entityUlid = null, ?string $gradeUlid = null, ?string $sectionUlid = null, ?string $search = null): PaginatedResult The complex's own pupils and every operable school's, in one list, ordered by school, then pupil; each StudentDto carries entityId and entityName; skipped_entities sits in extra education.students.read
GetOperableStudent(string $appPermission, string $studentUlid): StudentDto One pupil, with their school in entityId — the server's answer education.students.read
GetOperableStudentPhoto(string $appPermission, string $studentUlid): ?string The photograph's path on the public disk, or null — serve it through a route of your own education.student_photos.read
ListOperableStudentFingerprints(string $appPermission, int $page, int $perPage = 1000): array The card roster: students rows of {student_id, full_name, grade_id, section_id, entity_id, entity_name, fingerprints}, and meta with skipped_entities tool_keys.links.read

The scope in the last column is asked twice: of the complex's copy (else MissingScopeException) and of each school's copy. A managed school whose copy lacks it is named in skipped_entities ({id, name, missing_scope}) — never listed as a school with nobody in it — and, for a single pupil, answered as not found.

  • The probe answers with a value. ListOperableChildEntities never throws a refusal: refusal is missing_scope, not_the_app_root or one of the act words, with an empty list — ask it before you draw the complex's screen. A complex whose copy manages no school answers [the complex] and no refusal. Only a permission that is not yours is thrown. ActInChildEntity::refusal($appPermission) answers the same question about the person without listing anything.
  • The other four throw. A scope missing from the complex's copy — entity.hierarchy.read or the read's own — is MissingScopeException. Every other refusal is App\Exceptions\ChildEntitiesNotOperableException with a reason — not_the_app_root, already_acting, not_container, the actor words (no_actor, narrowed, missing_permission, device_elsewhere), and tree_too_large on the two rosters — rendered to a JSON or API caller as 403 {"error": "children_not_operable", "reason": "…"}.
  • The ceiling refuses; it never trims. ListOperableStudents and the card roster refuse a tree of more than 10,000 live pupils or 50 schools as tree_too_large, on every call, whatever it filters: a list cut short turns the pupils past the cut into strangers at the door.
  • One 404 for everything outside. GetOperableStudent and GetOperableStudentPhoto throw the same ModelNotFoundException for a pupil of another customer, a released pupil, a pupil of a school the complex does not operate, and a ULID that names nobody.
  • Fingerprints, never card codes. The roster has no codes key, and every fingerprint is under the complex's secret — the one the Okta app uses on a scan when it is open at the complex. So the gate's scans match these rows, and not a school's own roster.
  • The identity number and the photograph appear on a row only when the complex's copy and that pupil's school's copy both hold the scope; the identity part of a search runs only when every school searched granted it.
  • ListOperableStudents lists released pupils, as a school's own listing does; the card roster leaves them out.
  • key (on OperableEntityDto, and on ManagingEntityDto below) is the platform's number for the entity — the value GetActiveEntityId() answers inside that school — handed to your code for your own tables. toArray() leaves it out; never send it anywhere.

Who manages this copy — GetManagingEntity

__invoke(): ?ManagingEntityDto (scope entity.hierarchy.read): the container whose copy manages the copy you are standing in — id (ULID), key, name, and clock, a TenantClockDto whose source says which link of the chain answered (platform, tenant, country or default). Null for a copy nobody manages, which is the everyday answer. It is read from the live lock, so a complex that uninstalled its copy, or a school moved away, answers null at once. Inside an act the copy standing there is the school's, so it answers with the complex doing the writing — the place a school's settings fall back to.

The viewer inside the act

GetCurrentViewer inside your closure returns the complex's viewer — the complex's entityId, its roles and groups, and your permissions held there, because that is where the authority was asked — with actingInEntityId set to the school's ULID and isActingBeneath() true. Stamp the record with the school, never with entityId: inside the act, entityId is the complex. Outside an act nothing changes (actingInEntityId is null), and a device is never a viewer (null).

The school comes from the pupil, on the server

No header, no body field and no query parameter says which school a record lands in. A school_id sent by the client is a claim, even when it looks right. For a batch, ActForStudentsInTree places the pupils; for one pupil, GetOperableStudent($appPermission, $studentUlid)->entityId answers. A filter such as entityUlid on ListOperableStudents narrows inside the tree the authority already decided — a ULID outside it is an empty page, never a school opened.

Notifications land in the school

  • Inside an act, DispatchNotification sends under the school's copy: the guardians are the pupil's guardians in that school, and the delivery row is the school's.
  • A school's managed copy follows the complex's setting for a notification until somebody at the school configures that row: the complex's switch, channels, quiet hours and wording. The recipients always stay the school's own, and a provider template id carries over only when both entities send that channel through the same provider app. Once somebody at the school edits the row — and a row that was already switched on, reworded or changed before counts as edited — the school's own row governs, a deliberate off included, until they press «Follow ». Nothing in your code changes.
  • Dispatched from the complex's own context to the parents of a pupil of a school beneath it (parent_of_student), a notification reaches nobody: one delivery row, channel all, status failed, error recipient_in_child_entity:<school ULID> — …, and nothing is sent. It cannot be resent — a resend would run in the same place. Send it from inside the school, through ActInChildEntity or ActForStudentsInTree.

What is not here

  • Paired panels. The rule for a device is already in the platform — a panel paired to the complex acts through its role's grants, the same two keys; paired elsewhere it is device_elsewhere; a screen pinned to places is narrowed — but no platform surface opens your app for an operator panel yet. Today the one acting is a person.
  • RunAsChildEntity is not this. It asks nothing about the person: a school that installed your app is entered under its own grant — writes included — whoever is asking. Beyond the tree and grant checks it refuses only a narrowed door (narrowed) and a call from inside an act (already_acting) — «Acting inside a child» above. It is for scheduled tasks, queues and servers with nobody asking. On a request path, read with ?entities= or the tree reads above, and write with ActInChildEntity.
  • The launch marker is not yours to read. A native launch at a complex carries "operates": {"scope": "tree"} when the person, through the role they opened with, may operate the complex's schools for your app and its copy manages at least one; a school's launch — and any launch that does not qualify — has no operates key at all. Only the Okta app reads it: it is not in Okta.context() and never reaches your server. What you see of it is package:okta_face loading the complex's operated tree instead of one school (chapter «native mode», section «Who the recognition camera saw»).
  • Nothing to declare. No new manifest key, no new scope, no minContract. What makes it work is what this chapter already has: hierarchy.managed_install or hierarchy.reach (so there are managed copies), entity.hierarchy.read, and your permission in rbac_permissions.

A complex's departments — GET /entity/departments

A complex may split its schools into departments (optional): each has a manager, staff and its own schools, and a school sits in one department at most. A department's staff see their department's schools alone, on the platform and in your app alike.

In short: nothing to declare and no new scope. The platform fences the tree reads and acts to the person's department by itself, and GET /api/apps/entity/departments (behind entity.hierarchy.read) tells you the departments and where the person stands, so your app can draw schools under their department and explain a missing school instead of looking broken.

What the platform fences for you

For a person in a department of the entity your app is installed on, the schools outside it do not exist on every tree path:

Path Outside the department
/entity/children (and ?descendants=1) not listed
/entity/children/{id} and its logo 404 entity_not_found
?entity=<ulid> / ?entities=… entity_not_found for that school
ActInChildEntity child_entity_not_found
The operated tree reads (ListOperableChildEntities and its siblings) not listed

The whole tree is seen by anyone in no department, and by whoever holds tenants.children.manage_departments (the one who assigns schools to departments). A complex administrator who also manages a department chooses at sign-in between the «complex» card and the «manager of department X» card; the second fences them to that department. Your server on an installation token is never fenced: there is no person to ask about.

So never cache the school list from one person to show another: two staff of the same entity can get different lists.

The answer

{
  "data": [
    {
      "id": "01JB…",
      "name": "Boys' department",
      "description": null,
      "manager": { "name": "Khalid" },
      "staff": 4,
      "schools": ["01JA…", "01JC…"]
    }
  ],
  "viewer": {
    "manages": ["01JB…"],
    "member_of": [],
    "acting_department": "01JB…",
    "reach": ["01JA…", "01JC…"]
  }
}
  • schools are the schools' ULIDs as /entity/children gives them, so join on the id.
  • viewer is null when no person is behind the request (a server).
  • viewer.reach is null when the person sees the whole tree, else what they reach — and it can be empty: a department with no schools leaves its staff on nothing, not on everything.
  • viewer.acting_department is the department a complex administrator entered as, or null.
  • An entity with no departments answers data: [], and everything works as before.
use App\Services\PartnerApi\Entity\Hierarchy\ListDepartments;

$answer = app(ListDepartments::class)();   // embedded apps: the same shape

A mini app reads it with Okta.get('/api/apps/entity/departments').

Supporting departments in your app

  1. Read the schools from the platform every session, not from a stored copy: /entity/children gives exactly what this person reaches.
  2. Group the schools under their departments on the head-office screen: read /entity/departments, and show «no department» for schools none holds.
  3. Say why the list is short: viewer.reach not null ⇒ «showing your department's schools»; empty ⇒ «no schools in your department yet, ask the complex administrator» — never a silent empty screen.
  4. Do not build your own fence: do not read the departments table or compare schools yourself before reading or writing. The platform refuses what is outside the department, and an app that fences on its own drifts from it the day a department changes.
  5. Reports and totals across the tree take their schools from the same /entity/children, so a department's staff never sum schools they cannot see.

App lifecycle

Draft  →  Submitted  →  In Review  →  Approved  →  Published
                              ↓
                          Rejected
  • Draft: edit freely.
  • Submitted: in the review queue. Locked.
  • In Review: our team is looking at it.
  • Approved: green-lit. Will publish on next publish trigger.
  • Published: live in the marketplace, installable by tenants.
  • Rejected: returns to Draft with reviewer comments.

Embedded apps

Embedded apps live inside Okta but are forbidden from:

  • ✗ Importing App\Models\* (platform models)
  • ✗ Importing App\Services\* other than App\Services\PartnerApi\*
  • ✗ Reading platform env keys (DB_, REDIS_, etc.)
  • ✗ Reading .env directly

Use PartnerApi services exclusively. Each service automatically enforces its required scope. If the tenant hasn't granted your app that scope you'll get a MissingScopeException (HTTP 403).

You get a Postgres schema entirely separate from Okta's (m_<my_module>). PostgreSQL enforces the boundary at the role level.

Full boilerplate layout, the right way to access data, and tables guidance: see commit a164484 on prod.


Your app's database (migrations)

The migration files in your repo are the source of truth. There is no schema designer in the portal — there was one and it was removed, because it never wrote anything into your repo, so a drawn table became a third truth matching neither the repo nor the live database. You write an ordinary Laravel migration, push it, and import it.

The chain

database/migrations/ in your repo
        │  "Import from repo"  (or import_migrations_from_repo)
        ▼
the portal's migration store  ──→  manifest.database.migrations[]
        │
        ▼  provision_database (sandbox) / the portal button (production)
your app's own Postgres schema

Every link is required. Without the import, buildManifest() emits requiresDatabase:false and migrations:[], and okta-web refuses to create the database with "nothing to provision" — the import is also what flips requires_database and fills database_schema on your versions.

File shape

database/migrations/2026_07_20_101500_create_exam_papers.php
                    └─────┬────────┘ └────────┬───────────┘
                        version              migration name

YYYY_MM_DD_HHMMSS_<name>.php is mandatory. Anything that does not match is skipped silently.

version is the key to everything after that: apply order, matching on re-import, and the record of what ran. Re-timestamping an existing file makes a NEW migration as far as the platform is concerned — it does not edit the old one.

The baseline file 000000_create_<app>_database.php

The boilerplate ships it starting with 000000 deliberately: that name does not match the pattern above, so it is never imported. Creating the database itself is okta-web's step, and a file that does it again collides with it.

But the skip is by name, not by intent. Rename it to a real timestamp (2026_07_26_100000_create_<app>_database.php) and it becomes an ordinary migration: imported and shipped — usually with a timestamp older than your real migrations, which leads straight to the next error.

"Out-of-order migration" — and why it stops everything

Out-of-order migration "2026_07_26_100000" precedes already-applied
"2026_07_27_100001" on binding #21. Migrations are immutable once applied
— add a new corrective migration instead.

The applier walks migrations in version order and refuses to run one older than the newest already applied to that database. The alternative would be a lie: running an older migration after a newer one gives you a schema no other install has ever passed through, and makes the apply order differ between two databases claiming the same version.

The refusal aborts the whole run, not just that row. So one migration with a wrong timestamp freezes everything behind it: you read "7 applied, 1 pending" and nothing moves however often you press sync.

Getting out:

  1. If the row should never ship — the baseline file above being the textbook case — delete it from the list. It ran nowhere, so the deletion is completely clean and the block clears at once.
  2. If it is a real migration that arrived late with an old timestamp: give it a timestamp after the last applied one (a new file in your repo), then delete the old row and re-import.
  3. If it has already run somewhere, do not delete or edit it: add a new corrective migration. That is what "immutable once applied" in the error means.

Two statuses: draft and published

Status Where from Editable In the manifest
published imported from the repo ✗ SQL frozen ✓ in every later manifest
draft inline SQL typed in the portal ✓ ✗ until published

The import writes published directly — these are real release files, not drafts being iterated on. So "published" here does not mean somebody decided to release it: a file that was incomplete the day you pushed it arrives frozen.

Correcting a migration after it has arrived

Route one — a new migration. This is the normal one: an ALTER corrects what its predecessor did. Published SQL is frozen for a reason — a school's database has already run it, and rewriting it underneath makes what is in that database differ from what the manifest claims.

Route two — delete. Available on every row, published or draft, from the database table in the portal. Know what it does and does not do:

  • ✓ Removes it from every future manifest → new installs will not get it.
  • ✗ Undoes nothing already applied. A table created last week stays created.
  • ✗ Does not touch manifests already sent to okta-web; the difference starts at the next publish.
  • ✗ Does not touch the applied ledger. That ledger says what a real database executed, and editing it to match a decision made afterwards would leave the platform unable to say what is in that schema.

So if the migration has run on existing databases, deleting creates a permanent divergence between them and any new install. The portal shows a "ran on N databases" badge on the row and changes the confirmation text accordingly — read it: it is the difference between tidying a list and two schemas that will never match again.

When is deleting the right answer? An incomplete or broken migration that has not run on any database, or a row whose file is no longer in your repo at all.

Rows whose file is gone

The import adds and updates; it never deletes. So if you delete a file from your repo, push, and re-import, you get "imported successfully" back while the deleted file is still shipping in every manifest.

That is why the import reports how many there are and badges those rows "not in repo". They are not deleted automatically: the import runs against one ref, a file absent from it may be absent only from that branch, and it may already have run against a live database. The call is yours, on the row.

Inline SQL in the portal

For small corrections that do not warrant a repo round-trip. Saved as draft and published with the next version. Statements with no business in an app migration are refused — DROP DATABASE/SCHEMA/ROLE/USER, GRANT/REVOKE, ALTER ROLE/USER, COPY … FROM PROGRAM, and the server-file functions. The real safety net is not that list but the isolated schema: the connection role cannot touch another app's data at all.

MCP tools

Tool What it does
list_migrations Migrations in apply order + the requires_database flag per version
read_migration One migration's body — the copy the platform actually executes, which can drift from your repo
import_migrations_from_repo The "Import from repo" button, and it reports missing rows
provision_database Create the sandbox database and apply what has not been applied (production stays in the portal)
database_status Whether the database is provisioned, the last run's outcome, applied vs pending
diff_schema What your files describe against what is actually in the sandbox database

diff_schema always states its own coverage: a migration stored as a repo path carries no SQL here, so the checker cannot claim "undeclared" about something it never read.


Extending the student profile

An embedded app can add content to okta-web's student-profile page straight from code — no portal forms. Write a Livewire component in a fixed folder, annotate it with a single attribute, and the platform auto-discovers and registers it.

Embedded apps only. External apps surface their data over HTTP/webhooks, not through this registry.

Folder layout (shipped in the boilerplate)

Modules/<App>/app/StudentProfile/
├── Panels/    ← full cards (Panel)
├── Stats/     ← header-strip numbers (Stat)
└── Actions/   ← header buttons (Action)

Any annotated component under these folders is discovered and registered automatically at boot — there is no registration code to write.

Declaration: an attribute on the class

namespace Modules\MyApp\app\StudentProfile\Panels;

use App\Support\StudentProfile\Attributes\StudentPanel;
use App\Support\StudentProfile\StudentProfileZone;
use Livewire\Component;

#[StudentPanel(
    title: 'Attendance summary',
    permission: 'my_app.records.view',
    order: 50,
    zone: StudentProfileZone::Main,
)]
class AttendanceSummary extends Component
{
    public string $studentHashid;   // the only identifier passed — never a numeric id

    public function render()
    {
        return view('my-app::student-profile.attendance-summary');
    }
}
  • key and component are derived automatically from the class.
  • Only title/label + permission are required; the rest is optional.

Mandatory rules

  • Use studentHashid only — never a numeric id.
  • Every contribution declares a permission in the <feature>.<resource>.<action> shape.
  • This surface is embedded-only.

The four types (attribute parameters)

1) #[StudentPanel] — a full card

Param Required Description
title ✓ Card title
permission ✓ View permission
order — Order (default 100, lower first)
zone — StudentProfileZone::Main (wide column) or ::Sidebar
icon — Icon
description — Short description
key — Override the derived key

2) #[StudentStat] — a header-strip number

Param Required Description
label ✓ Label
permission ✓ View permission
order — Order
icon — Icon
color — primary/success/warning/danger/…

The component itself is the live widget that renders the value.

3) #[StudentAction] — a header button

Param Required Description
label ✓ Button text
permission ✓ View permission
url or event ✓ Open a URL or dispatch an event
params — Event params (with event only)
order — Order
variant — primary/secondary/danger/…
icon — Icon

url takes precedence over event. The event is broadcast via Alpine $dispatch and caught by Livewire #[On] or wire-elements (openModal).

4) Zone

  • Main — the wide centre column.
  • Sidebar — the side column.

The unified block chrome (<x-profile-block> / <x-profile-stat>)

The student profile is a unified block dashboard: every card renders inside platform-owned chrome that shows its source (your app) with its accent colour, a sync-status pill, last-synced time, and an "open in app" link. To make your card blend into the board, wrap your panel content in <x-profile-block> (instead of a bare <x-card>):

{{-- my-app::student-profile.attendance-summary --}}
<x-profile-block
    :title="__('my-app::profile.attendance')"
    :source="\App\Support\StudentProfile\BlockSource::resolve('my-app')"
    status="live"                    {{-- live | syncing | offline | error --}}
    :last-synced-at="$syncedAt"
    :open-in-app-href="route('store.show', 'my-app')"
    :open-in-app-label="__('my-app::profile.open')">

    {{-- your content: chart / table / list — tenant data via App\Services\PartnerApi\* only --}}

    <x-slot:actions>…</x-slot:actions>   {{-- optional: ⋮ menu --}}
</x-profile-block>
  • BlockSource::resolve('<module-slug>') gives you a stable source identity (name / accent / icon) — don't invent a colour per card. (Or pass source-label / accent / icon directly.)
  • Status is dynamic: pass status and last-synced-at at render time from your real sync state. status="offline" shows an offline state — use <x-slot:offline> for the fallback (link-to-connect) content.
  • Stat tiles (#[StudentStat]) use <x-profile-stat> (:label + :value + :source) so they appear in the stats row attributed to your app.
  • The platform owns the chrome; you supply content + status only. The "data sources" bar and the source filter are derived automatically from your registered blocks (no extra config).

How it shows in the portal

The partner portal does not run your code, so its Student Profile tab is read-only: it lists the contributions okta-web discovered after a sandbox install, and the published manifest's studentProfile block is derived from them. You write the code — everything else is automatic.

Your component receives studentHashid only: <livewire:my-app::student-profile-summary :studentHashid="$studentHashid" />.


Landing widgets

An embedded app can contribute blocks to the tenant's public landing-page builder: your widget appears in the editor's "Your apps" category (only for tenants that installed your app), the tenant drags it onto their public site, and visitors see it like any platform block — without the tenant writing a single line of code. You declare the widget in the manifest and ship one Livewire component; the platform provides the rest:

  • The editor card, carrying your app's name and icon.
  • The block settings form, generated automatically from your settings_schema declaration — you write no editor Blade at all.
  • Block behaviour (move / copy / columns / show-hide) comes from the builder itself; the widget is stored inside the page document as one bridge block of type partner-widget — installing a new app adds no block class to the platform.
  • Security, free and mandatory: the runtime context on the public page is built by the platform, not by you.

Embedded apps only. A landing widget renders in-process inside okta-web as a Livewire component; an app hosted elsewhere has nothing to render there. Declaring landing_widgets with any other integrationType fails the version publish.

Read the security model before you build. It decides what your widget can reach at all — people and money data will never reach your widget on a public page, whatever scopes your installation holds.

Declaring widgets in the manifest

{
  "integrationType": "embedded",
  // ... rest of the manifest ...
  "landing_widgets": [
    {
      "key": "admission_register",
      "label": "التسجيل في القبول",
      "label_en": "Admission Registration",
      "description": "نموذج تقديم طلب قبول من موقع الجهة العام.",
      "description_en": "Admission application form on the tenant public site.",
      "icon": "clipboard",
      "category": "conversion",
      "livewire_component": "my-app-landing-register",
      "interaction": "static",
      "scopes": ["education.grades.read"],
      "cache_ttl": 300,
      "settings_schema": {
        "fields": [
          { "key": "title", "type": "text", "label": "العنوان", "label_en": "Title",
            "localized": true, "default": "سجّل الآن", "min": 1, "max": 80 },
          { "key": "show_deadline", "type": "toggle", "label": "إظهار آخر موعد", "default": true }
        ]
      },
      "preview": { "thumbnail_url": "https://cdn.example.com/widgets/register.png" }
    }
  ]
}

Fields

landing_widgets is an array of at most 10 widgets per app.

Field Required Value
key ✓ ^[a-z][a-z0-9_]{1,39}$ — unique within the app. The global identifier is the composite <module-slug>:<key>; stored blocks on tenant pages reference the widget by it. Changing it after publish = a new widget and every old block loses its reference — name it well in the first release.
label / label_en label ✓ Card name in the editor (2–60 chars). label_en is used per locale and falls back to label.
description / description_en — Short text under the card (≤ 300 chars).
icon — Icon name (≤ 40 chars, default puzzle). An unknown value falls back to the default icon with no error.
category — The card's editor group: hero | content | media | conversion | layout (default conversion).
livewire_component ✓ Livewire component alias, flat form only. An alias containing :: is refused at publish — Livewire 4 never resolves it anyway (the same trap as the App Control Panel).
interaction — static (default) or interactive. Interactive is a much heavier contract — see Interactive widgets.
scopes — Read scopes the render needs at runtime (≤ 20), each ending strictly in .read — any other verb fails the publish. This is a restriction, not a grant: what you declare here enters the read-leg intersection, and what you do not declare never reaches the widget even when the installation holds it.
public_writes — Write scopes the widget performs on behalf of an anonymous visitor (≤ 10), each ending strictly in .write. Three publish-time conditions: the shape; every entry being an active scope marked public_writable in the okta-web catalog (checked against the live catalog — and refused rather than accepted unchecked if the catalog cannot be reached); and interaction=interactive — a static widget is served from cache with no round trip in which a write could ever happen.
cache_ttl — Seconds the rendered HTML is cached on the public page (0–86400, default 300). Ignored entirely for interactive widgets — a cached body would replay one visitor's form state to another.
settings_schema — The declarative settings form — next section.
preview.thumbnail_url — Card thumbnail. Absolute https URL only.

The settings form: settings_schema

{ "fields": [ { /* field */ }, ... ] }   // at most 20 fields

The types are a closed vocabulary — exactly eight: text, textarea, number, toggle, select, color, image, link.

There is no html type and there never will be (declaring it is refused at publish with an explicit message). That is a security decision, not a gap: every setting value passes through Blade escaping at render time, so a tenant admin (or whoever compromises their account) cannot inject script into the public page through your widget's settings. If you need rich output, build it in your component from structured fields.

Field properties:

Property Applies to Description
key all ^[a-z][a-z0-9_]{0,39}$, unique within the widget.
type all One of the eight types.
label / label_en all Field title in the inspector (label required, ≤ 120 chars).
localized all true = the editor writes the value for the open locale only; false/absent = it mirrors the value to both locales. Your component always receives single-locale settings — the locale of the page being served — and never needs to know which fields are localized.
default all Initial value on insert. Checked against the type: a toggle accepts only a bool, a number only a number, and a select's default must be one of its declared options.
help / help_en all Help text under the field (≤ 300 chars).
min / max text, textarea, number Length bounds for text, value bounds for numbers. On other types they are silently dropped.
options select [{"value": "...", "label": "...", "label_en": "..."}] static list (≤ 50 options, unique values). A select without options fails the publish.
options_endpoint select Reserved and currently refused. The shape (<provider>:<name>) is validated, then the declaration is explicitly refused at publish while no resolver exists on the platform — a readable publish error today beats a field that silently degrades into a text box in every tenant's editor. Use static options.

An example covering all eight types:

{ "fields": [
  { "key": "title",    "type": "text",     "label": "العنوان", "localized": true,
    "default": "سجّل الآن", "min": 1, "max": 80 },
  { "key": "intro",    "type": "textarea", "label": "المقدمة", "localized": true, "max": 400 },
  { "key": "max_rows", "type": "number",   "label": "عدد الصفوف", "default": 5, "min": 1, "max": 20 },
  { "key": "show_fees","type": "toggle",   "label": "إظهار الرسوم", "default": false },
  { "key": "layout",   "type": "select",   "label": "التخطيط",
    "options": [
      { "value": "grid", "label": "شبكة", "label_en": "Grid" },
      { "value": "list", "label": "قائمة", "label_en": "List" }
    ],
    "default": "grid" },
  { "key": "accent",   "type": "color",    "label": "لون التمييز", "default": "#0ea5e9" },
  { "key": "banner",   "type": "image",    "label": "صورة الترويسة" },
  { "key": "policy",   "type": "link",     "label": "رابط سياسة القبول", "localized": true }
]}

Two points govern how storage works:

  • The block is stored per locale inside the page document, and non-localized fields are duplicated across both locales with the same value — there is no "shared" store.
  • A block inserted before you added a new field in a later release will not carry its key. Therefore: every settings read in your component goes through ?? $default, always.

Writing the component

The widget is an ordinary Livewire component inside your app, under the full Embedded code contract — being on a public page is no exemption:

  1. Data exclusively through App\Services\PartnerApi\* — the policy scanner fails the build on anything else, and every service checks its scope internally, so whatever is not in the context is refused automatically.
  2. Never assume a user. On the public page auth()->user() is always null. Code that assumes a session breaks there even though it works in the editor preview.
  3. Developer settings work: GetAppSetting / GetAppSettings run inside the widget like any runtime context — reading your own app's store only, so you can change widget behaviour without publishing a new version.
  4. UI to platform standards: <x-…> components and semantic tokens — the widget renders inside the tenant's page with their theme and brand, and hard-coded colours break that.
  5. Assume failure: an exception from your component on the public page shows no error page — it produces a silent void where the widget was (see below).

A static widget (static) renders once and its output is cached for cache_ttl seconds: any wire:click or wire:model in a static blade freezes into dead attributes in cached HTML. Right for display: lists, stats, cards, links. Its component receives exactly settings + locale at render time and nothing else — anything it needs about the tenant it reads from the active ModuleContext like every other PartnerApi service.

The security model (two asymmetric surfaces)

The two render surfaces are not symmetric, and the difference between them is the whole design:

  • Editor preview: the tenant admin is signed in, so the context resolves the usual way (the same path as the rest of your app's pages). The preview sees what your app normally sees.
  • Public page: the visitor is entirely anonymous — no user, no session. The platform rebuilds the runtime context per request from the tenant alone (resolved from the domain), and the context's scopes are the union of two sets, each an independent three-way intersection:
READ  = (granted to the install) ∩ (catalog public_surface)  ∩ (widget scopes)
WRITE = (granted to the install) ∩ (catalog public_writable) ∩ (widget public_writes)

context = READ ∪ WRITE

They are deliberately NOT one path with a merged flag: read and write answer different questions ("may a stranger see this?" versus "may a stranger cause this?"), and a single path would let a future edit to one silently move the other. The verb filter runs twice on each leg: a .write inside scopes[] is not a read declaration whatever the manifest says, and a mis-marked catalog row cannot ride a grant into the context.

The write leg carries two extra conditions: a platform-wide interactive flag checked inside the resolver itself, and a public write ticket that is only minted after the barriers pass (see Interactive widgets). No ticket = an empty write set — which is what every read path gets, by default, forever.

The governing principle: what never enters the context needs no guard. No new permission check, no extra middleware, no deny-list to maintain — a deliberately poor context, and the same existing guard that protects every PartnerApi service refuses everything outside it.

The practical consequence you must know before you build: student, guardian, employee and financial scopes are never marked public_surface or public_writable. Their exclusion is structural, not policy: it is not a middleware rule someone could miss — they simply never enter the context, so every call to them is refused as if they were never granted. A widget calling ListStudents on a public page will fail always, whatever you declare in scopes. If your widget concept needs people data on a public page, the concept itself needs rethinking.

Today's public_surface list (may grow by platform decision, very conservatively):

  • countries.directory.read — public reference data (country list).
  • countries.education_levels.read — the reference ladder of education stages.
  • education.grades.read — the tenant's own grade levels (institutional structure a school already publishes on its site — no person and no record in it).

The scopes and public_writes fields in your declaration are the third leg of their intersections, and they work in your favour: declare the minimum, and a vulnerability in your widget's code can only reach what you declared — even when your installation holds more.

Failure is a silent void — and cached

A widget failure on the public page — an exception, a refused scope, an alias that does not resolve, an uninstalled app — produces a silent void where the block was, and never takes the tenant's site down. A static widget's failure is also cached for 30 seconds to prevent hammering: a crashing widget is not re-executed for every visitor. Turn that around for development: your fix may not show up immediately because of the failure cache, and you will think "the widget just doesn't work" while the real cause is a swallowed exception — check the okta-web logs, and do not use repeated page refreshes as a test.

On the other surface — an interactive widget's round trip — failure is not silent: a request that cannot be attributed to a widget the tenant actually owns is refused with 403. Silence for the page, candour for the round trip.

Interactive widgets

A widget that accepts input from an anonymous visitor (a submission form, say). A much heavier contract than flipping the field, and every sentence of it is enforced by code, not by review.

The base class is mandatory — and CI enforces it

Your component must extend App\Support\LandingWidgets\PublicWidgetComponent — a class the platform owns. The landing-widget-base-class rule in the policy scanner fails the build for any class under app/LandingWidgets/ in your app that does not extend it: a plain Livewire\Component there is not "a widget without the extras" — it is a public write path with no context derivation and no barriers, failing open instead of closed. (An intermediate base class of your own that extends PublicWidgetComponent is a legitimate shape — opt it out on the declaration line with // partner-policy:allow=landing-widget-base-class.)

Why: /livewire/update remembers nothing

The first paint is made by the platform while it holds the tenant, the install and the context. But every wire:click after it goes to /livewire/update — a route that knows no landing pages and no tenants; all it has is the snapshot the browser sent. So the base class re-derives the full context on every hydrate from live data: real tenant → the plan still includes widgets → the interactive surface is on → the widget is still in the tenant's current catalog → declared interactive → context minted through the intersections. #[Locked] on the identity properties does real work but is not the guard: a forged tenantUlid resolves against live data and is refused because that tenant has not installed your app. Even the settings carried on the snapshot are re-clamped to the widget's current schema before any use.

mount(), boot(), dehydrate() and exception() are all final on purpose. Your hook is mounted(), which runs once after the context is up. And never store the context on a property — per-request values are remade on every hydrate.

At render time your component receives — in addition to settings — the identity triple moduleSlug / widgetKey / tenantUlid plus widgetLocale, because it will rebuild the context from them on every round trip. (The choice is made by inspecting the resolved class, not the declared interaction field.)

Writes: withPublicWrite() and the three barriers

Every write on a visitor's behalf goes exclusively through:

public function submit(): void
{
    $this->validate([...]);

    $this->withPublicWrite('submit', function (): void {
        app(\App\Services\PartnerApi\...\CreateSomething::class)(...);
    });
}

In order, all of them in the base class so you cannot decline one:

  1. honeypot — a hidden field the platform owns (see the partial below). Filling it = a bot.
  2. throttle — per (IP × tenant × module × widget × action); the default is 5/minute, or maxPerMinute per call. throttle() is also available to you standalone, to protect an expensive read.
  3. captcha — only active when the platform has a provider bound and the tenant switched it on (or the platform forces it globally). The platform ships no provider by default, so the barrier is absent rather than blocking a tenant with a check that cannot run.

A refusal is not an exception you handle: the callback simply does not run, withPublicWrite returns null, and $widgetBarrierMessage carries a translated sentence the partial shows the visitor.

After all three pass — and only there — the public write ticket is minted, and the context is re-derived with it so the write leg enters; the callback runs; then a finally re-derives without the ticket — the write clearance lasts exactly as long as the write, and the following render runs with a read-only context. Nowhere else in the platform mints the ticket, so there is no path to a public write that skipped a barrier.

The form-guard partial — a mandatory include

@include('landing-builder::blocks.partner-widget.partials.form-guard')

Once, inside your component's root element. It renders the honeypot, the guard marker and the refusal message. Enforcement comes from the HTML actually rendered: the base class inspects its own render output and derives the guard state from it — a form without the partial arrives at the next write with a permanently failing honeypot barrier, and there is no way around it. The misleading symptom when you forget it: the form shows and appears to work, and every write returns null with a barrier message.

The deployment trap: SESSION_DOMAIN — know it before you ship

An interactive widget = a POST with a session and CSRF, and the cookie only returns to the server if it covers the served host's domain:

  • SESSION_DOMAIN unset: every tenant domain (custom or subdomain) gets its own session. This is the arrangement that works.
  • SESSION_DOMAIN pinned to the platform domain: a tenant serving its page from example.com gets no session cookie at all — the page renders, the form shows, and the first click is a 419 in front of a visitor who cannot report it.

The platform detects the second case rather than "fixing" it (a platform-wide cookie domain is usually set deliberately): it suppresses the interactive widget on that host and shows a polite card instead of a form that ends in 419, with a warning line in the okta-web logs. Not your bug and not your code's — a deployment precondition. Test on a custom domain if your tenants use them.

The only public_writable scope today

education.admission_applications.write — an admission application submitted by a visitor on the tenant's own public site. A mailbox: things go in and never come out — it has no .read counterpart at all (a public read would expose other applicants' submissions to any visitor), and it is marked is_dangerous, so the install-grant screen paints it in danger colours on purpose.

Extending the list is a platform decision, never requested through the manifest, and governed by three non-negotiable rules: intake only (creating a new inbound record about the visitor themselves — no update, no touching an existing record), no people, no money, and never a delete. Have a use case? Bring it to the platform team.

Lifecycle

  1. Publish: you add landing_widgets to your version's manifest and publish it. Validation happens on acceptance (shapes, limits, integrationType=embedded, public_writes markings against the live catalog) — a non-conforming block refuses the version.
  2. Install: the tenant installs your app from the store and grants scopes as usual.
  3. Appearing in the editor: the tenant's widget catalog is derived per request from its installations — installed apps' widgets only.
  4. Insert: the tenant admin drags the widget in, and the inspector shows the settings form generated from settings_schema. The preview renders with the admin's context.
  5. Page publish: on the public site the widget renders with the anonymous context (the union of the two intersections). A static widget's output is cached for cache_ttl seconds (the cache key carries a settings fingerprint — editing the settings and republishing shows immediately); an interactive widget renders live on every request.
  6. Uninstall: the widget disappears from the editor catalog immediately. Blocks already placed on published pages lose their reference and fall into the same failure path: a silent void, no error on the tenant's site. (The tenant deletes the orphan block from its editor whenever it wants.)
  7. Version update: the catalog reflects the installed version's manifest. A new settings field shows up in old blocks only through your code's default (?? $default); deleting a widget or changing its key orphans old blocks exactly like an uninstall.

Registering the alias and the component

// In your app's ServiceProvider::boot() — the flat alias form is mandatory.
use Livewire\Livewire;

Livewire::component(
    'my-app-landing-register',
    \Modules\MyApp\Livewire\Landing\RegisterWidget::class,
);
<?php

namespace Modules\MyApp\Livewire\Landing;

use Livewire\Component;

class RegisterWidget extends Component
{
    /** settings_schema values for the served locale, as the editor stored them. */
    public array $settings = [];

    public function render()
    {
        // Reads exclusively via App\Services\PartnerApi\* — the service
        // checks the scope internally and refuses what is not in the context.
        $grades = app(\App\Services\PartnerApi\Education\Grades\ListGrades::class)(
            onlyActive: true,
        );

        return view('my-app::livewire.landing.register-widget', [
            'grades' => $grades->data,
            'title' => $this->settings['title'] ?? 'سجّل الآن',
        ]);
    }
}

That is all for a static widget. An interactive one extends PublicWidgetComponent instead of Component, lives under app/LandingWidgets/, and writes through withPublicWrite() as in its section above.

Common errors

Symptom Cause Fix
Widget never renders Alias contains :: Flat form in livewire_component and in Livewire::component() alike
"Just doesn't work" during development Silent failure + 30s failure cache Check the okta-web logs; do not use page refreshes as a test
Works in preview, empty on the public site A scope not marked public_surface or not declared in scopes The preview runs with a full admin context; test with the poor context before publishing
Buttons unresponsive on the site A static widget with wire:* in it Interactivity requires interaction=interactive and its full contract
Every write refused with a barrier message Missing @include(... form-guard) Once, inside the root element
419 on the first click on a custom domain SESSION_DOMAIN pinned to the platform domain A deployment precondition — see the SESSION_DOMAIN trap
A setting value shows as literal text All settings values are escaped at render Intended — never HTML through settings

Pre-publish checklist

  • integrationType=embedded and the landing_widgets block ≤ 10 widgets.
  • Every key matches ^[a-z][a-z0-9_]{1,39}$, unique within the app — and its name is final.
  • livewire_component in the flat form (no ::), registered with Livewire::component() in boot().
  • scopes is the minimum the render actually needs — not a copy of the installation's scope list.
  • No people/finance scope called from the public render path — it will never be marked public_surface.
  • settings_schema ≤ 20 fields, types from the eight, and every settings read behind ?? $default.
  • A static widget has no wire:* in its blade.
  • Interactive: extends PublicWidgetComponent, under app/LandingWidgets/, and every write inside withPublicWrite().
  • Interactive: @include('landing-builder::blocks.partner-widget.partials.form-guard') once inside the root element.
  • All public_writes are marked public_writable in the catalog and interaction=interactive.
  • Interactive tested on a custom tenant domain if your tenants use them.
  • The render tested with the poor context (no user, scopes only), in both page locales, and with an empty data state.
  • preview.thumbnail_url is a working https URL, and the card reviewed in the editor.

External apps

When you create an External app you provide:

  • webhook_url: HTTPS URL that receives tenant events.
  • webhook_events: subscription list.
  • redirect_urls: OAuth callback URLs.

When a tenant installs your app, an installation token unique to that (tenant, app) pair is issued. Use this token on every API call. The token is long-lived until rotated or revoked. On rotation you receive a webhook partner.installation.token_rotated; the old token remains valid for 15 minutes for graceful deploy.


Notification apps

A notification app registers as a pluggable channel that the rest of the platform consumes through one unified interface. You build the provider once; the platform calls it with send(recipient, message) regardless of the underlying medium (WhatsApp, SMS, Push, Slack, ...).

Manifest block

{
  "integrationType": "notification",
  "notification": {
    "channels": ["whatsapp", "sms"],
    "delivery": "api",
    "api": {
      "send_endpoint": "https://your-app.example/notifications/send",
      "auth": "hmac"
    },
    "embedded": {
      "provider_class": "Modules\\AcmeSms\\Providers\\AcmeSmsProvider"
    },
    "capabilities": {
      "supports_templates": true,
      "supports_media": false,
      "supports_bulk": true,
      "max_bulk_recipients": 1000
    },
    "settings_ui": {
      "has_settings_page": true,
      "livewire_component": "partner-apps.acme-sms.settings"
    }
  }
}

Validation rules enforced by the platform:

  • notification.channels is a non-empty array of known values: whatsapp, sms, push, slack, email, telegram, voice.
  • notification.delivery must be api, embedded, or hybrid.
  • If delivery is api or hybrid ⇒ notification.api.send_endpoint is required and must be HTTPS.
  • If delivery is embedded or hybrid ⇒ notification.embedded.provider_class is required (must be a valid fully-qualified PHP class name like Modules\Foo\Providers\Bar).
  • A notification block is rejected unless integrationType is set to notification.

Scopes (granted automatically)

Picking integrationType=notification auto-attaches:

  • notifications.providers.send (required)
  • notifications.logs.read (optional)

Path 1: delivery=api

The platform sends:

POST https://your-app.example/notifications/send
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Delivery-Id: <uuid>

{
  "channel": "whatsapp",
  "recipient_identifier": "+966500000000",
  "message": {
    "body": "Sarah was absent on 2026-09-15",
    "title": "Student absent",
    "template_id": "student_absent_v2",
    "variables": { "student_name": "Sarah", "date": "2026-09-15" },
    "media": []
  },
  "metadata": {
    "notification_key": "okta-attendance.student_absent",
    "app_slug": "okta-attendance",
    "delivery_id": 48213
  }
}

metadata is the notification's identity — next section. message.variables are the catalogue variables exactly as the sending app passed them, not only the rendered body; message.template_id is what the tenant chose, if anything (else null).

The notification's identity — what your provider learns about every message

Why this section exists: an official WhatsApp number (Cloud API) delivers a business-initiated message only as a template approved by Meta. A provider that wraps every notification in one default template is stuck there unless it knows which notification it is carrying — and nothing in the payload used to say. Every send() call — from every channel, and from the tenant's «send test» button — now carries three fixed keys in metadata:

Key Example Meaning
notification_key okta-attendance.student_absent The catalogue key — what you pick the right template by
app_slug okta-attendance The sending app — not your provider
delivery_id 48213 The partner_notification_deliveries row this send belongs to — what you quote in a support ticket, or tie Meta's status callback to

Alongside them:

  • variables — the catalogue variables as the app sent them (student_name, date, …), not only the rendered body. Fill the template's slots from these; body is the ready text for a provider without templates, or for a generic one-slot template.
  • templateId — the template the tenant chose for this notification from yours (next section), or null, in which case you decide: one you map from notification_key, or your default.
  • test: true appears only when the call comes from the tenant's test button; the three identity keys arrive with it in full, so pick for the test what you would pick for the real send — a test that succeeds on a default template while the real send fails on an unapproved one is exactly what this section exists to prevent.

A trap worth its own line: on the queue worker your provider runs inside its own installation context. Ask AppContextManager «which app am I?» and you hear your own name, not the sender's. The sender's name reaches you in metadata.app_slug only.

The raw POST /api/apps/notifications/send path (an app sending through you directly, with no catalogue) carries app_slug and recipient_type only — there is no key and no delivery row there.

Approved templates the tenant picks — supports_templates

If your provider declares notification.capabilities.supports_templates: true, the tenant's «customise message» modal on /settings/notifications shows one more field per notification: approved provider template. The choice reaches you on every send as NotificationPayload::$templateId (message.template_id on the api wire).

  • An embedded provider can list its templates so the field is a picker, by implementing a second, optional contract beside PartnerNotificationProvider:

    use App\Contracts\ListsNotificationTemplates;
    
    final class WaCloudProvider implements PartnerNotificationProvider, ListsNotificationTemplates
    {
        /** @return list<array{id: string, name: string, description?: ?string}> */
        public function templates(string $channel): array
        {
            // The number's templates approved at Meta — called inside YOUR
            // installation context, so the tenant's number is the tenant's.
            // Never throw; return [] when you cannot answer.
            return $this->meta->approvedTemplates($channel);
        }
    }
    

    The list is closed: an id outside it is refused at save, so no non-existent template ever reaches Meta.

  • An api provider has no class here, so the tenant types the template id exactly as you know it.

  • No supports_templates ⇒ no field, and templateId is always null.

Your own logic does not change because the choice exists: templateId first when it arrives, else whatever you map from notification_key, else your default.

The installed apps' notification catalogue — your source for building templates

An approved template is built before the first send, and you cannot build one for a key you do not know. So a provider has an endpoint that reads the notification catalogue of every app installed on the tenant it is installed in — behind its own scope, notifications.providers.send, with nothing extra to declare:

GET /api/apps/notifications/catalog?channel=whatsapp
Authorization: Bearer <installation token>
{
  "tenant_id": 4821,
  "apps": [
    {
      "app_slug": "okta-attendance",
      "display_name": "Okta Attendance",
      "installation_id": 918,
      "notifications": [
        {
          "key": "okta-attendance.student_absent",
          "display_name_ar": "غياب طالب",
          "display_name_en": "Student absent",
          "audience": ["guardian"],
          "variables": { "student_name": "string", "date": "date" },
          "default_template": "{{ student_name }} was absent on {{ date }}",
          "default_channels": ["whatsapp", "in_app"],
          "tenant": { "enabled": true, "channels": ["whatsapp"], "template_id": "student_absent_v2", "has_custom_template": false }
        }
      ]
    }
  ]
}
  • key is what will reach you in metadata.notification_key, and variables (name → type) are exactly the names that arrive in variables on every send — your template's slots are built on them.

  • tenant is the tenant's state for the key when it has configured it (null when it has not yet): whether it is enabled, on which channels, and which of your templates it chose. default_channels is the developer's ceiling; tenant.channels is what the tenant narrowed it to.

  • ?channel=whatsapp narrows to the keys that would actually go out on that channel here (the tenant's choice when it has one, else the developer's ceiling) — that is the list that needs a template per entry.

  • This tenant's apps only, active declarations only; no recipients, no payloads, no delivery log of any app but yours.

  • installed_apps lists every active install on the tenant with its notification count (your own provider included, at zero) — so when apps comes back empty, read it: installs with no notifications mean the apps declare none; an empty list means your token is on another tenant (a partner has one sandbox per tenant type — school, daycare… — each with its own installs), and tenant_id in the response is what you compare with the tenant open in your browser.

  • A token on the sandbox: installing there issues no token by itself. From the sandbox admin account, POST /api/store/modules/{slug}/installation-token/rotate issues the install's first token (with its approved scopes) and returns it once — or use the in-process call below, which needs no token.

  • An embedded provider reads it without HTTP — the same class from your own code; it lives under App\Services\PartnerApi\*, so the policy scanner allows the import:

    use App\Services\PartnerApi\Notifications\Catalog\GetInstalledAppsNotificationCatalog;
    
    // On your settings pages (the middleware built your installation context)
    // or inside send() on the worker (you arrive wrapped in it) — tenant and
    // installation come from the context, never from an argument.
    $catalog = app(GetInstalledAppsNotificationCatalog::class)('whatsapp');
    
    foreach ($catalog['apps'] as $app) {
        foreach ($app['notifications'] as $n) {
            // $n['key'] is what reaches you in metadata.notification_key;
            // $n['variables'] names your template's slots.
            $this->ensureTemplateFor($n['key'], array_keys($n['variables']));
        }
    }
    

    Outside any installation context (from tinker, say) it refuses with no_app_context — it never answers for an unknown tenant.

The catalogue changes as apps publish new versions, so re-read it periodically or on the first unknown key that reaches you — never cache it forever.

Verify the signature:

$expected = hash_hmac('sha256', $timestamp . '.' . $body, $signingSecret);
if (! hash_equals($expected, $signature)) abort(401);
if (abs(time() - (int) $timestamp) > 300) abort(401);

Expected response: 200 with { "provider_id": "..." }. 4xx = permanent failure, no retry. 5xx = transient failure, retried once.

Path 2: delivery=embedded

Ship a class implementing the contract:

namespace Modules\AcmeSms\Providers;

use App\Contracts\PartnerNotificationProvider;
use App\Services\PartnerApi\Notifications\NotificationPayload;
use App\Services\PartnerApi\Notifications\NotificationResult;

final class AcmeSmsProvider implements PartnerNotificationProvider
{
    public function send(NotificationPayload $payload): NotificationResult
    {
        try {
            // Which notification, from which app, which delivery row — see «identity».
            $key = $payload->metadata['notification_key'] ?? null;   // okta-attendance.student_absent
            $template = $payload->templateId                          // the tenant's choice, if any
                ?? $this->templateFor($key);                          // else what you map from the key

            $response = SmsClient::send([
                'to'        => $payload->recipientIdentifier,
                'text'      => $payload->body,
                'template'  => $template,
                'params'    => $payload->variables,                   // catalogue variables fill the slots
                'reference' => $payload->metadata['delivery_id'] ?? null,
            ]);
            return NotificationResult::success(providerId: $response['id'] ?? null);
        } catch (\Throwable $e) {
            return NotificationResult::failure($e->getMessage());
        }
    }
    public function supports(string $channel): bool { return $channel === 'sms'; }
    public function isConfigured(): bool { return ! empty(config('acme-sms.api_key')); }
}

Path 3: delivery=hybrid

The platform builds a HybridNotificationProvider that tries the embedded path first and falls back to api on isConfigured()=false or send() returning a failure. Useful for canary releases.

Settings UI (settings_ui) — optional

If your app needs an in-tenant settings page, declare it in the manifest. The platform mounts your registered Livewire component under /partner-apps/notification/providers.

Per-version overrides

Every notification field above is overridable per version from the Integration tab. Blank fields inherit module-level values at manifest-build time.


Payment apps

A payment app registers a pluggable payment gateway/method that a tenant installs once, after which any other app can charge through it via a uniform payment contract in okta-web. You build the provider once and the platform calls it with charge(...) regardless of the actual gateway (Tabby, Tamara, Noon Payments, ...).

Manifest block

{
  "integrationType": "payment",
  "payment": {
    "payment_methods": ["card", "mada", "applepay", "stcpay", "tabby", "tamara", "bank_transfer", "wallet", "cash"],
    "delivery": "api",
    "api": {
      "charge_endpoint": "https://your-gateway.example/okta/charge",
      "auth": "hmac"
    },
    "embedded": {
      "provider_class": "Modules\\AcmePay\\Payments\\AcmePayProvider"
    },
    "capabilities": {
      "supports_refunds": true,
      "supports_partial_refunds": false,
      "supports_installments": true,
      "min_amount": null,
      "max_amount": null,
      "currencies": ["SAR"]
    },
    "settings_ui": {
      "has_settings_page": true,
      "livewire_component": "vendor-x-payment-settings"
    }
  }
}

Validation rules the platform enforces:

  • payment.payment_methods — a non-empty array of known values: card, mada, applepay, stcpay, tabby, tamara, bank_transfer, wallet, cash.
  • payment.delivery must be api, embedded or hybrid.
  • delivery=api/hybrid ⇒ payment.api.charge_endpoint must be HTTPS.
  • delivery=embedded/hybrid ⇒ payment.embedded.provider_class must be a valid FQCN.
  • A payment block is rejected unless integrationType=payment.

Custom payment methods

Beyond the nine standard methods, a payment provider may declare custom methods via a custom_methods block alongside payment_methods:

"payment": {
  "payment_methods": ["card", "mada", "my_regional_wallet"],
  "custom_methods": [
    { "key": "my_regional_wallet", "label": "محفظتي الإقليمية", "label_en": "My Regional Wallet", "kind": "wallet" }
  ],
  "delivery": "api"
}

Rules:

  • key: matches ^[a-z][a-z0-9_]{1,31}$, must NOT equal any standard value, and is unique within the list.
  • label (Arabic) and label_en (English): required, 2–60 chars each.
  • kind: required, one of card | wallet | bnpl | transfer | cash | other.
  • Every custom_methods key MUST appear in payment_methods (and vice versa: any non-standard payment_methods entry must have a matching custom_methods entry). The create wizard appends custom keys into payment_methods automatically — you never tick them by hand.
  • custom_methods is omitted from the manifest entirely when empty.

Consumer apps see custom methods (with their labels) via GET /api/apps/payments/methods exactly like the standard ones, and pass the key when creating a charge.

Scopes (granted automatically)

Picking integrationType=payment auto-attaches:

  • payments.charges.update (required — lets you update charge status)
  • payments.charges.read (optional — read charge status)

Path 1: delivery=api

okta-web sends your endpoint a signed charge request:

POST https://your-gateway.example/okta/charge
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Charge-Id: <uuid>
X-Okta-Idempotency-Key: <key>

{
  "method": "tabby",
  "amount": 150.00,
  "currency": "SAR",
  "description": "Annual subscription",
  "customer": { "name": "Ahmed", "phone": "+966500000000", "email": "a@example.com" },
  "metadata": {},
  "charge_ref": "chg_01H..."
}
  • Signature: X-Okta-Signature = hmac_sha256("<timestamp>.<body>", signingSecret) — same scheme as notification.
  • No automatic retry from okta-web; idempotency is handled via the X-Okta-Idempotency-Key header so a replayed request never double-charges.

Expected response (2xx):

{
  "status": "pending",
  "provider_ref": "tabby_pay_123",
  "redirect_url": "https://checkout.tabby.ai/..."
}
  • status is one of pending | paid | failed.
  • redirect_url is optional — for flows that redirect the customer.

Status updates flow back later via POST /api/apps/payments/charges/{ref}/status using the provider's own installation token (scope payments.charges.update).

Path 2: delivery=embedded

You ship a class inside okta-web implementing App\Contracts\PartnerPaymentProvider (charge / refund / supports / isConfigured). Never throw from charge() — wrap in try/catch and return a failure result.

Path 3: delivery=hybrid

The platform tries the embedded provider first and falls back to the api path on isConfigured()=false or a non-permanent failure.

Consuming payments from your app (Embedded only)

Strict rule: payment consumption (create / read / list / refund a charge) is available exclusively to Embedded apps. Any attempt from an External app is rejected with payment_consumption_embedded_only. Reason: reacting to a charge outcome happens via an in-process event, not a webhook — an externally-hosted app has no way to listen for an internal Laravel event, so it would stay blind to the final outcome if allowed to charge. Payment providers themselves (integrationType=payment) don't consume either — their role is only to receive the charge call and push status updates (payments.charges.update), never to initiate charges for other apps.

How it works, end to end

  1. The tenant installs one or more payment providers (integrationType=payment) from /partner-apps/payment/providers — an administrative step the tenant takes; your app has no part in it.
  2. Your embedded app requests a charge through the uniform CreateCharge contract without knowing which gateway the tenant actually installed.
  3. The platform (ResolveTenantPaymentProvider) resolves the tenant's active provider that supports the requested method and executes the charge through it (the same api/embedded/hybrid paths described above from the provider's side).
  4. Your app follows up on the result in two complementary ways:
    • Immediately, from the response itself (status: paid|pending|failed).
    • Later, via the ChargeUpdated event the platform fires on every status transition — needed for pending charges that later become paid through BNPL gateways once the customer completes checkout.

Required scopes

Pick these from the scope picker when building your app (payments resource):

Scope Grants
payments.methods.read Read the tenant's available payment methods
payments.charges.create Create a charge — requires idempotency_key
payments.charges.read Read one charge or list all of your own charges
payments.refunds.create Refund a charge you previously created

payments.charges.update is not in the consumer picker — it is exclusive to payment providers themselves (auto-granted only for integrationType=payment, to update the status of the charges they execute). Calling it from a consumer context is rejected with payment_status_update_provider_only.

Services (in-process — the primary path)

Same pattern as the rest of App\Services\PartnerApi\* — no HTTP, no exceptions for normal control flow, scanner-clean. Every service returns an array (no Eloquent, no schema leaking):

Service Signature HTTP equivalent Scope
ListAvailableMethods __invoke(): array GET /api/apps/payments/methods payments.methods.read
CreateCharge __invoke(array $input): array POST /api/apps/payments/charges (+ Idempotency-Key) payments.charges.create
GetCharge __invoke(string $chargeRef): array GET /api/apps/payments/charges/{ref} payments.charges.read
ListCharges __invoke(array $filters = []): array GET /api/apps/payments/charges payments.charges.read
RefundCharge __invoke(string $chargeRef, array $input): array POST /api/apps/payments/charges/{ref}/refund payments.refunds.create

ListCharges returns only the charges your own app created (filtered by your installation id) — supported filters: status, method, from, to, plus page/per_page (up to 100), newest first.

Example 1 — selling a feature/service from your app

// Modules/SchoolPortal/app/Services/Reports/SellPremiumReport.php

namespace Modules\SchoolPortal\Services\Reports;

use App\Services\PartnerApi\Payments\CreateCharge;

final class SellPremiumReport
{
    public function __construct(
        private readonly CreateCharge $createCharge,
    ) {}

    /**
     * @return array<string, mixed>
     */
    public function __invoke(string $orderId, float $amount): array
    {
        return ($this->createCharge)([
            'method' => 'tabby',
            'amount' => $amount,
            'currency' => 'SAR',
            'description' => 'Premium analytics report',
            'metadata' => [
                'feature' => 'premium_reports',
                'order_id' => $orderId,
            ],
            'idempotency_key' => "premium-report-{$orderId}",
        ]);
    }
}

From the calling Livewire component:

$result = app(SellPremiumReport::class)($orderId, 49.00);

if ($result['status'] === 'paid') {
    // Unlock the feature immediately — the response came back paid
    // synchronously (card/mada).
    $this->dispatch('feature-unlocked');
    return;
}

if ($result['status'] === 'pending' && $result['redirect_url']) {
    // BNPL gateways (Tabby/Tamara) — send the customer to finish there.
    $this->redirect($result['redirect_url']);
    return;
}

if ($result['status'] === 'failed') {
    $this->addError('payment', __('Payment failed. Try another method.'));
    return;
}

// pending with no redirect_url — wait for the ChargeUpdated event (example 2).

Never re-call CreateCharge on a page reload or a network failure — always pass the same idempotency_key; the platform returns the same stored charge instead of charging twice (no automatic retry on charging).

Example 2 — unlocking a feature when the payment completes (ChargeUpdated)

A charge that started pending (BNPL gateway) later transitions to paid/failed outside the original CreateCharge request — no webhook reaches you for this (consumption is in-process only); listen for the event inside your own app's ServiceProvider boot():

// Modules/SchoolPortal/app/Providers/SchoolPortalServiceProvider.php

namespace Modules\SchoolPortal\Providers;

use App\Events\PartnerPayments\ChargeUpdated;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\SchoolPortal\Services\Reports\GrantOrRevokePremiumReport;

class SchoolPortalServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Event::listen(function (ChargeUpdated $event): void {
            // Mandatory filter — the platform broadcasts the same event
            // to every listening app, not just yours.
            if ($event->consumerModuleSlug !== 'school-portal') {
                return;
            }

            if (($event->metadata['feature'] ?? null) !== 'premium_reports') {
                return;
            }

            $orderId = $event->metadata['order_id'] ?? null;

            match ($event->status) {
                'paid' => app(GrantOrRevokePremiumReport::class)->grant($orderId),
                'refunded', 'partially_refunded' => app(GrantOrRevokePremiumReport::class)->revoke($orderId),
                default => null, // failed — nothing further; pending never reaches here
            };
        });
    }
}

Important notes about the event:

  • It only fires on an actual status change — you will not receive it for the initial pending creation (that is not a "transition"), only for pending→paid|failed or a later paid→refunded|partially_refunded. A failed refund does not fire it.
  • Make your handler idempotent — check your own current state before granting/revoking, in case of a listener restart or rare duplicate.
  • Always handle refunded/partially_refunded — the event may reach you later for a charge that previously unlocked a feature; turn it off.
  • Available fields: chargeRef, tenantId, consumerModuleSlug, previousStatus, status, method, amount, currency, refundedAmount, metadata, occurredAt.

Example 3 — reconciliation

use App\Services\PartnerApi\Payments\ListCharges;

$page = app(ListCharges::class)([
    'status' => 'paid',
    'from' => '2026-07-01',
    'to' => '2026-07-31',
    'per_page' => 100,
]);

foreach ($page['data'] as $charge) {
    // $charge['charge_ref'] is the only public identifier — never a numeric id
    // Match $charge['metadata']['order_id'] against your own records
}

$page['pagination']; // total / per_page / current_page / last_page

Refunds

use App\Services\PartnerApi\Payments\RefundCharge;

$result = app(RefundCharge::class)($chargeRef, ['amount' => null]); // null = full refund

RefundCharge guards:

  • The charge must be owned by your app (created by it — otherwise it is treated as not found).
  • Its current status must be paid (no refund on pending/failed).
  • amount ≤ the remaining refundable balance (amount − refunded_amount) — previous partial refunds are respected automatically.
  • No automatic retry here either; a failed refund does not fire ChargeUpdated.

Per-version overrides

Every payment field above is overridable per version from the Integration tab. Blank fields inherit module-level values at manifest-build time.


App Control Panel

The developer builds their app's control panel inside the app code; the platform owns the design + access controls and opens it from the partner portal via a signed link. Each app has two panels — one per environment (Production and Sandbox) — based on the version published/installed in each. On top of what you build yourself, the platform automatically adds an "App Data" section to every panel's chrome — an isolated key/value store for your app that you manage with zero code (see App Data below).

How it works

  1. Declare the panel in the manifest via a developer_ui block.
  2. Two cards appear on the portal's Integration tab: Production and Sandbox. Each is enabled only when a version is live in that environment and the panel is declared in the manifest.
  3. The "Open panel" button hits a lightweight okta-partners endpoint (GET /partner/modules/{slug}/dev-panel/{env}) that mints a short-lived JWT (5 min) and redirects you to the panel page on the target okta-web environment: <web base>/partner-dev/{slug}/panel?token=<jwt>.
  4. The JWT is HS256-signed with the same shared bridge secret each environment's outbound bridge calls already use (production ⇒ outboundToken, sandbox ⇒ sandboxOutboundToken) — no new secret. Claims: { iss:"okta-partners", aud:"okta-web-devpanel", sub:<partner user id>, module:<slug>, env:"production"|"sandbox", iat, exp:iat+300, jti }.

Manifest block

For any integration type (embedded / payment / notification / external):

"developer_ui": {
  "has_developer_page": true,
  "livewire_component": "my-app-developer-panel"
}
  • embedded / payment / notification ⇒ set livewire_component (a Livewire component alias rendered inside okta-web; pattern ^[a-z0-9][a-z0-9-]*(::|-)[a-z0-9-.]+$ — use the hyphen form, e.g. my-app-developer-panel; a :: alias is not resolvable by Livewire 4).
  • external ⇒ set url instead (an HTTPS URL you host on your own environment — your environments are your own, so you get one card that opens the URL directly in a new tab):
"developer_ui": {
  "has_developer_page": true,
  "url": "https://partner.example/console"
}

The two are mutually exclusive (XOR): the manifest emits only the key matching the integration type. The block is overridable per version from the Integration tab; a blank value inherits the module-level value.

Panel sections (sections)

Who owns what — everything below follows from this split:

The platform owns You own
The panel shell (chrome) and outer frame The content of your own sections
The header, app name and app icon One Livewire component per section
The section navigation and its order Each section's label and icon (from the platform's list)
The built-in sections (App Data, health, ...) —

The platform fixes the built-in sections in every panel; sections is your half: app-specific sections hung off the platform's navigation, each rendering one of your Livewire components inside the shell. The key is optional — omitting it gives you a single-section panel, exactly the behaviour that existed before the key did.

"developer_ui": {
  "has_developer_page": true,
  "livewire_component": "my-app-developer-panel",
  "sections": [
    { "key": "trips", "label": "الرحلات", "label_en": "Trips",
      "icon": "map", "livewire_component": "my-app-trips" },
    { "key": "drivers", "label": "السائقون", "label_en": "Drivers",
      "icon": "users", "livewire_component": "my-app-drivers" }
  ]
}
Field Required Rule
key ^[a-z][a-z0-9-]*$ — must start with a lowercase letter (never a digit), then lowercase letters/digits/hyphens, no underscore, max 32 chars, and unique within the array (the panel navigation is keyed by it).
label Max 60 chars (the Arabic/default-locale label in the navigation).
label_en Max 60 chars.
livewire_component The same alias rule as the panel's own component — and :: is flattened the same way, because Livewire 4 cannot resolve a name containing ::.
icon One of: home, chart, list, map, settings, bell, users, box, calendar, file.
  • Maximum 10 sections; array order is navigation order.
  • The key rule here is deliberately narrower than what okta-web accepts (^[a-z][a-z0-9_-]{0,39}$): okta-partners produces the manifest and okta-web consumes it, so the producer is the stricter side. A key we let through but okta-web rejects would be a publish failure caused by data we waved through.
  • External apps have no sections: they host the whole panel themselves, so there is no platform shell to hang sections off — the key is dropped exactly as livewire_component is.
  • A half-valid block never ships: a section missing or malforming key, label or livewire_component is dropped whole — a nav entry that routes nowhere is worse than a missing one. A duplicate key keeps only its first occurrence, an unrecognised icon is dropped silently (the section stays, iconless — decoration never fails a declaration), and the list is capped at 10.

Panel design policy

The panel lives inside a shell the platform owns, and every rule below follows from that. All of them are literal:

  1. Do not build a header or a nav bar inside your section. The shell already provides both; anything you add renders duplicated above the platform's own header.
  2. <x-…> components only. No raw <button> / <input> / <select> / <textarea> / <table> — the visual identity lives inside the components.
  3. No fixed colours such as gray / zinc / slate / indigo; use the platform tokens (neutral, primary, success, warning, danger).
  4. No max-w-*. The shell enforces the width; adding it breaks the grid and clips long content.
  5. The section's name comes from label/label_en, not from the blade — the platform draws the title in the navigation.
  6. Icons come from the allowed list only; never ship your own SVG into the navigation.

Rules 2–4 are enforced by the UiScanner design scanner and the CI gate over every *.blade.php in your repo, so a violation never reaches production.

The controls

  • Design: the panel blade goes through the same UiScanner / CI gate as the rest of your pages — <x-…> components + platform tokens only, no raw HTML, no gray/zinc/slate/indigo, no max-w-*.
  • Access: platform-issued signed link only. The developer context has no tenant — you see your app's aggregate health across all installs, never a single tenant's data. Your panel component can only reach one namespace: App\Services\PartnerApi\DeveloperPanel\* — the read-only aggregates (GetInstallsCount, ...) plus App Data CRUD under DeveloperPanel\Settings\* (see the next section).

Full example

Livewire component (shipped in the boilerplate under app/DeveloperPanel/Panel.php.example):

namespace Modules\MyApp\app\DeveloperPanel;

use App\Services\PartnerApi\DeveloperPanel\GetInstallsCount;
use Livewire\Component;

class Panel extends Component
{
    // The only two inputs the platform passes — never a tenant id.
    public string $moduleSlug = '';
    public string $environment = 'production';

    public function render(): \Illuminate\View\View
    {
        $installs = app(GetInstallsCount::class)($this->moduleSlug);

        return view('my-app::developer-panel.panel', [
            'installs' => $installs,
        ]);
    }
}

The blade uses platform components only:

<div class="space-y-6">
    <x-badge :color="$environment === 'production' ? 'success' : 'warning'" variant="soft">
        {{ $environment }}
    </x-badge>

    <x-card>
        <div class="p-4">
            <p class="text-3xl font-bold text-[var(--color-neutral-900)]">{{ $installs }}</p>
        </div>
    </x-card>
</div>

Available stats

The App\Services\PartnerApi\DeveloperPanel\* read surface on okta-web exposes aggregates across all of your app's installs (never a single tenant's rows) — e.g. installs count (GetInstallsCount), active installs, and error counts. Any payment charge surfaced in these aggregates is called a "دفعة".

App Data

A key/value (JSON) table scoped strictly to your app — a low-friction way to store operational settings/flags you want to tune between releases without a new publish.

  • Zero-code manager UI: an "App Data" section appears automatically inside the panel chrome — every app gets full CRUD (add/edit/delete a key) with no code of your own.
  • Two service surfaces:
    1. From your own panel component (namespace allowed only in the panel context): App\Services\PartnerApi\DeveloperPanel\Settings\{ListSettings, PutSetting, DeleteSetting} — each takes your app's moduleSlug.
    2. From your app's embedded code at runtime, in front of tenants (read-only, no writes): App\Services\PartnerApi\AppSettings\ {GetAppSetting, GetAppSettings} — cached for ~60 seconds.
  • Limits: 100 keys max per app, each value ≤ 64KB, key format ^[a-z][a-z0-9_.-]{0,119}$.
  • Isolation: fully server-enforced — the panel session context is derived only from the verified JWT (iss=okta-partners, aud=okta-web-devpanel); your app's session can never touch another app's data.
  • The pattern: tune your app's behavior (feature flags, limits, provider parameters, display texts) from the panel between releases — no republish needed.

Runtime read from your app's code (e.g. inside a service under App\Services\PartnerApi\*):

use App\Services\PartnerApi\AppSettings\GetAppSetting;

$maxItems = app(GetAppSetting::class)('max_items_per_page', 20);

Write from an action inside your panel component:

use App\Services\PartnerApi\DeveloperPanel\Settings\PutSetting;

public function saveMaxItems(): void
{
    app(PutSetting::class)($this->moduleSlug, 'max_items_per_page', (string) $this->maxItems);

    $this->dispatch('saved');
}

Browser tools

A browser tool is code that runs inside a system the school already uses — Noor, typically — not inside Okta. You write it, it ships inside your app's version, and Okta serves it to the Okta Tools extension in the user's browser. Valid for any integration type: a tool is a capability your app ships, not a kind of app, so nothing gates it — not integrationType, not another switch.

The governing rule: the extension runs it, Okta serves it

This rule changed. Partners used to host a .user.js file and Okta listed the link for the user to install into Tampermonkey or similar. That is no longer a supported path:

  • The user downloads nothing and needs no userscript manager.
  • The Okta Tools extension (Chrome Web Store) is the only thing that runs a tool.
  • You write the body into the script field on the version, and Okta serves it from its own origin, to the extension alone.

Three things follow that were not possible before:

  1. What a school runs is what was published and reviewed. It cannot change under them between reviews.
  2. A code update reaches everyone with no reinstall of anything. Publish a version; the extension picks it up.
  3. No auth wall silently kills auto-update, and no partner URL rots a year later leaving a dead tool nobody notices. Those were the three most common failures of the old path, and hosting removed all of them.

The old shape is still accepted: a published version carrying a url ending in .user.js with no script is neither rejected nor hidden — dropping it would have hidden a tool that works today for tenants who installed it. But do not write new tools that way; none of the three benefits above apply to it.

delivery: webstore is unchanged: an extension you distribute through the Chrome Web Store, where the URL is the whole product.

Declaring one

In the portal, on your app's page: Integration tab → Browser tools → "Add a tool". The fields:

Field Required Rule
key ^[a-z][a-z0-9-]*$, ≤ 32 chars, unique within your app. Okta addresses the tool as <module>:<key>
name ≤ 80 chars. name_en optional, same limit
description — ≤ 300 chars; description_en likewise
delivery userscript or webstore
script for userscript the tool body, ≤ 128 KB
world — isolated (default) or main. Injected tools only — read the next section before choosing
url for webstore absolute https to the Web Store listing, ≤ 500 chars
icon — absolute https. A broken icon is stripped and the row survives
version — ≤ 20 chars, defaults to 1.0
match for userscript one pattern per line, ≤ 20 patterns, ≤ 200 chars each

At most 10 tools per app. List order is display order for the tenant — your only control over which one they see first.

world: the most dangerous field here

The execution world is a functional choice, not a preference, and its effect only shows at runtime, in the user's browser:

Value What the tool sees
isolated (default) the DOM tree only. Not the page's JavaScript globals or functions
main the page's own world: its globals, its functions, its frameworks

The practical rule: if your tool calls anything the page defines — $find in ASP.NET apps, jQuery, an app's own client object — it needs main. Under isolated those are simply undefined, and it fails with is not a function while everything else looks fine: the tool is enabled, the buttons are drawn, the page works. That is exactly what happened to the Noor export tool, and it is the class of failure users never report, because they don't know what was supposed to happen.

Start at isolated — if your tool only reads and writes the DOM that is enough, and it is the narrower privilege. Raise it to main for this reason and no other.

An unknown value (page, say) drops the whole tool rather than downgrading it to isolated: a silent downgrade ships the exact failure described above.

The cross-checks that drop a row

The partner portal checks these and blocks the save, but know them — they are the most common mistakes:

  1. delivery: userscript with no script. The body travels inside the version, so a tool without one is not a tool. (Sole exception: the legacy shape with a .user.js url.)
  2. script and url on the same row. One source of truth per tool — otherwise there is no telling which body a tenant is running.
  3. delivery: webstore with a script body. Nothing runs it: the Web Store extension is the artifact, so the body here is dead code that reads as live.
  4. delivery: userscript with no match pattern. It is the tenant's only disclosure of which sites your tool touches, and what the extension requests permission for.
  5. delivery: webstore with a url ending in .user.js. A mistyped delivery.

Why we block you instead of accepting and fixing: BuildBrowserToolsBlock drops any row it cannot ship whole. Had we let it through, the form would save successfully, the manifest would come out one tool short, and you would find out from a tenant reporting a tool that never appeared. Failing at the form is the difference between "the portal told me my tool was incomplete" and "my tool vanished silently".

match patterns

Chrome match-pattern syntax: <scheme>://<host><path>.

https://noor.moe.gov.sa/*
https://*.moe.gov.sa/reports/*

Three things people get wrong:

  • * in the scheme means http|https only — not "any scheme".
  • *.example.com includes example.com itself, not only its subdomains.
  • The path is mandatory: https://noor.moe.gov.sa with no / is invalid.

Keep them as narrow as they can be. A broad pattern means a wider permission prompt for the user, and one more reason for them to refuse to run your tool.

The manifest shape

{
  "browser_tools": [
    {
      "key": "noor-helper",
      "name": "مساعد نور",
      "name_en": "Noor Helper",
      "description": "Speeds up grade entry in Noor.",
      "delivery": "userscript",
      "script": "(function () { 'use strict'; /* ... */ })();",
      "world": "main",
      "icon": "https://partner.example/tools/icon.png",
      "version": "1.2",
      "match": ["https://noor.moe.gov.sa/*"]
    }
  ]
}

A ==UserScript== header is not required — the extension does not read it; the declared match and world are what govern injection. Write one only as documentation.

Version-level override

The list is owned at the app level and every version inherits it. A version may carry its own list, from the version editor ("Browser tools" card → "Customise the list for this version").

Know what that means before you do it:

  • An own list beats the app list when the manifest is built. Any tool you add later at the app level will not reach that version.
  • An empty own list is not inheritance, it is an explicit declaration that this version ships no tools at all. Use it to withdraw a tool in one version and keep it in another.
  • Going back: "Return to the app list" on the same card clears the override and restores dynamic inheritance.

And since the body is now part of the version, an override also freezes the tool's code on that version.

Updating a published tool

Edit script, raise version, publish. The extension compares versions and re-fetches the body when they differ, and skips the fetch entirely when nothing changed. Nobody is asked to reinstall the extension or the tool.

Freezing the number freezes the tool on its old body for everyone who enabled it, with no visible symptom to anyone — so raise it on every edit, however small.

What a tool cannot do

A tool never reaches Okta, in either world: it carries no installation token, reads none of your scopes, and cannot call /api/apps/* as your app. world: main widens what it sees of the target page, not what it holds of Okta. If you need data from Okta, that is your app's job, not its tool's.


Manifest

Every app emits a manifest.json describing its capabilities:

{
  "moduleId": "warehouse",
  "displayName": "Warehouse Manager",
  "version": "1.0.0",
  "category": "logistics",
  "integrationType": "embedded",
  "description": "...",
  "scopes": [
    { "key": "education.students.read",  "required": true,  "reason": "to list student rosters" },
    { "key": "education.students.write", "required": false, "reason": "to record results" }
  ]
}

External apps add external block; notification apps add notification block; payment apps add payment block (see above). Embedded apps that extend the student profile add a studentProfile block (see Extending the student profile). An app a complex may install, managed, in its schools — or that a complex's install places in its schools — adds a hierarchy block (see A complex installs your app in its schools and A complex's install covers its schools).

Manifests are generated automatically from the partner-portal form fields — you don't write them by hand except in advanced cases.


Role permissions inside your app (rbac_permissions)

Scopes say what the platform lets your app read. This block says what your app lets the school's staff do inside it: who sees the attendance page, who approves a request, who exports the report. Permissions created in the organisation and granted to its roles, guarded in your code with can() — like any platform permission.

Shape

{
  "moduleId": "okta-hdor",
  "rbac_permissions": {
    "tenant-admin": [
      "okta_hdor.attendance.view",
      "okta_hdor.attendance.update",
      "okta_hdor.reports.export"
    ],
    "teacher": [
      "okta_hdor.attendance.view",
      "okta_hdor.attendance.update"
    ]
  },
  "rbac_labels": {
    "feature": { "ar": "الحضور", "en": "Attendance" },
    "permissions": {
      "okta_hdor.attendance.view":   { "ar": "عرض الحضور",   "en": "View attendance" },
      "okta_hdor.attendance.update": { "ar": "تسجيل الحضور", "en": "Record attendance" },
      "okta_hdor.reports.export":    { "ar": "تصدير التقارير", "en": "Export reports" }
    }
  }
}
  • Keys are GLOBAL role names on the platform (tenant-admin, teacher, …). A role the platform does not know is skipped silently at install, so do not invent roles.
  • Every name is <feature>.<resource>.<action>, and the feature is your app's moduleId in snake_case: okta-hdor → okta_hdor. The platform groups the organisation's permission editor by the first segment and creates the name exactly as written, so a name outside your group (education.students.delete, say) lands inside the platform's own group and shadows its vocabulary — which is why it is refused by name at sync and never reaches publish. Specific verbs (view, create, update, delete, export, approve), not manage.
  • rbac_labels is what the school sees. The platform composes permission titles from its own language files and shows the raw key for anything it does not know — and your resources are by definition unknown to it. Without this block the school admin sees a group called okta_hdor containing okta_hdor.attendance.view. One title for the group, one per permission, Arabic and English (80 characters max); a title for an undeclared name is dropped.
  • The block is capped at 100 permissions.

How it reaches the version

The block is read from manifest.json on the branch by the version editor's "Sync from manifest" button (or the MCP tool sync_from_manifest), and shown read-only on the "Role permissions" card — with every refused entry and its reason. You can also set it directly with set_rbac_permissions and read it back with get_rbac_permissions, but keep the repository as the source of truth: the code calling can('okta_hdor.attendance.view') lives there, and the two names must match character for character.

Then the checker runs: validate_manifest refuses a name outside your group and warns about a permission with no title before you publish.

When they are created in the organisation

On first install AND on every publish — not at install alone. When you publish a version that declares a new permission, it is created in every installed organisation and granted to its named roles as the update reaches them; the school does not reinstall.

What you declare for a role is ALL that role holds of yours

On every publish the platform reconciles its own roles with your manifest: a role you NAME in rbac_permissions ends up holding what you declared for it and nothing else of yours — so anything an older version declared for that role and you stopped declaring is revoked from it.

  • A role you do not mention is untouched. Silence is not «revoke everything»: an app that never mentioned tenant-admin never claimed anything about it, and reading silence as a revocation would take screens away from whoever runs the school.
  • To empty a role, name it with an empty list: "administrator": []. That is the only sentence that says «this role holds none of my permissions», and your only way to take back a grant an older version made.
  • The school's own groups are never touched («Accounting», «Staff affairs»), nor is a permission handed straight to a person. Those are the school's decision, not yours.
  • And nothing outside your namespace: the reconciliation only ever sees names starting with your own <feature>..

Why it has to exist: the rows that grant a permission to a platform role carry no organisation column — one row is every school at once. So an app that declared sixteen permissions for administrator in an early version and then stopped declaring them left every administrator in every school holding payroll and staff-file editing, with no way for the developer to take it back and nothing in the school saying where it came from.

What the platform CREATED in the organisation stays: the permission remains in the school's list (so it can put it in a group of its own); what is revoked is its grant to the platform role. And name a permission right once — renaming means two live names in every school.

In code

// A Livewire page — the guard in mount(); the menu hides the link by the same name:
protected ?string $permission = 'okta_hdor.attendance.view';

public function mount(): void
{
    abort_unless(auth()->user()?->can($this->permission), 403);
}

php artisan okta-hdor:make-page Attendance --permission=okta_hdor.attendance.view generates the guard and the menu entry together. Hiding a link is not access control; the page is what gets guarded.

What is not here: no scope is requested from the organisation and no consent screen — these are permissions inside your app over your app's data, and the school distributes them across its roles from its roles settings exactly as it does for any platform feature.


API

A public, structured API reference with the full endpoint catalog, scopes, and cURL samples is at API reference.

Base URL: https://getokta.io/api/apps. Auth: Authorization: Bearer <installation_token>.

Available endpoints (whoami, entity + entity/children (+ /{id} and /{id}/logo) + entity/ancestors, education., employees.directory, reports.builder. — see commit a164484 for the full table). Report templates — the school's own letterheads — are GET /reports/templates and POST /reports/templates/{id}/render, with reports.builder.write adding POST/PATCH/DELETE /reports/templates for an app that proposes one; see Printing on the school's letterhead. Full list: https://partners.getokta.io/docs/openapi.json.

Every read of an entity's data (education/*, employees/directory, tool-keys/*, reports/*) accepts ?entities=<ulid>,<ulid> or ?entity=<ulid> to read the entities beneath from a complex — for a person holding tenants.children.view_members, never for an installation token. See the chapter «Companies and complexes: the tree and its schools' data».

Pagination: ?page=2&per_page=50 (max 100/page). Responses include data, total, per_page, current_page, last_page.

Idempotency: write operations accept Idempotency-Key: <uuid>. Same key within 24 hours returns the cached response.


Webhooks

Each webhook is an HTTP POST with a JSON body, signed with HMAC-SHA256 over <timestamp>.<body>. Verify:

$expected = hash_hmac(
    'sha256',
    $request->header('X-Okta-Timestamp') . '.' . $request->getContent(),
    $YOUR_WEBHOOK_SECRET,
);
if (! hash_equals($expected, $request->header('X-Okta-Signature'))) abort(401);

Reject X-Okta-Timestamp outside ±5 minutes and cache X-Okta-Delivery-Id for 15 minutes for replay protection.

Retry schedule: 30s → 2m → 10m → 1h → 6h. After 6 attempts the delivery is marked terminal and shown in "Webhook Deliveries".

Respond 2xx within 10 seconds.


Notifications catalog

Every partner app declares a notifications catalog of its own — the list of events the app can dispatch to end users, with each event having a stable key, semantics, variables, and default delivery channels. Partners define the catalog from the partner dashboard; it's shipped to okta-web on publish and surfaces on the tenant's /settings/notifications page where they enable what they want per installation and pick delivery channels.

Distinction from the integration type: section Notification apps above is about an integration type where the app itself is a delivery-channel provider (WhatsApp/SMS/Push). The catalog here is different: it's available to every app type (Embedded/External/Notification) to declare the catalog of events the app itself emits, regardless of which channels carry them.

Philosophy: declare-first

Your app cannot dispatch a notification from code before declaring the key in the catalog. This is enforced through two gates:

  1. NotificationScanner in CI: scans the codebase for any DispatchNotification('<key>', ...) call and fails the PR when <key> isn't in manifest.json["notifications"].
  2. Runtime guard on okta-web: a key the ingested catalog does not declare is refused by name — a validation exception from DispatchNotification inside the module, 422 with {errors: {key: [...]}} on POST /api/apps/notifications/dispatch. A declared key the school has not configured yet (an installation that predates the version adding it) is seeded for it disabled and the attempt is recorded as skipped_disabled in the delivery log. Nothing is dropped silently.

Tenants need to know upfront every notification your app might fire so they can decide whether to enable it, over which channels, and for whom.

Lifecycle

Draft version  →  Edit catalog freely  →  Submit  →  Approved  →  Published
       │                                                              │
       │                                                              ▼
       │                                                       Catalog frozen
       │                                                       Shipped to okta-web
       │                                                       Tenants can install
       │
       └──── Create new version ──→ Catalog cloned forward ──→ Edit freely ──→ ...

Key points:

  • The catalog is bound to a version, not the module.
  • Auto-clone on new version: every notification is copied forward as a starting point.
  • Frozen at publish: once a version is published, its catalog becomes read-only.
  • Continuous sync to GitHub: every edit updates manifest.json["notifications"] on the version's branch.

Anatomy of a notification entry

Field Type Notes
key string <your-slug>.<dot.path>. Lowercase, snake_case, 3+ parts. Immutable after creation.
display_name_ar / display_name_en string What the tenant sees. Bilingual, required — a notification with no Arabic name shows its raw key (your-app.thing_happened) inside an Arabic UI, which reads as a bug in your app.
description_ar / description_en text Optional context.
variables_schema map { name → php-type } describing the payload.
default_template text Your own default wording, with {{ variable_name }} placeholders. It is what the organisation sees (and edits from) in the customise box, and what is actually sent until they customise it. Without it the box opens empty — treat it as practically required.
audience array Who the notification is aimed at: guardian / student / staff / admin. Shown on the tenant's preferences row ("aimed at"); informational — actual routing stays with the dispatch recipient.
default_channels array Subset of email, sms, whatsapp, push, in_app, webhook_out. A ceiling: tenants narrow it and can never widen it.
severity enum info / warning / critical.
is_active bool Toggle without deleting. An inactive key disappears from the school's settings page and is not seeded for new schools; a dispatch from an installation that never configured it is refused as undeclared. A school that enabled it before the toggle keeps receiving it as long as your code dispatches it — the flag stops the spread, not the sends; stopping a feature means stopping the dispatch.

How your template lives at the tenant

  • The tenant's customise box opens on your default wording, ready to edit — never on an empty field.
  • A tenant that keeps your text unedited stays inheriting it: improve the wording in a later version and every non-customising tenant gets the improvement automatically. A tenant that edited is pinned to its own text until it clears the box (clearing and saving restores inheritance).
  • The tenant's test-send button renders your template with type-aware dummy values: numbers as real numbers, dates as today, strings as «variable_name» — so make sure the template reads well even with these stand-ins; they are the tenant's first impression of your message.
  • If the tenant's channel provider is bound to approved templates (an official WhatsApp number), the tenant may also pick one of the provider's templates for this notification in the same box. Nothing for you to do: the provider receives your notification key and your variables (metadata.notification_key + variables) and fills the template from them — so make your declared variables what the message text actually needs, not what is easy to compute.

How to declare

From the partner dashboard: Apps → pick your app → "Notifications" tab. Direct URL: /dashboard/modules/<your-slug>?tab=notifications.

  • Pick the version (latest draft selected by default).
  • Add notification → type the key suffix (the <slug>. prefix is added automatically), fill display name (ar/en), variables, the default message text with its placeholders, aimed at, channels, severity, save.

Dispatching from code

One call, two arguments — the key and the payload — and nothing else: no third argument, no channel inside the payload, no helper.

use App\Services\PartnerApi\Notifications\DispatchNotification;

app(DispatchNotification::class)('hr-pro.leave_request.approved', [
    'recipient' => ['type' => 'host_user', 'id' => $employee->user_ulid],
    'variables' => [
        'employee_name' => $employee->name,
        'leave_days'    => 5,
        'start_date'    => '2026-04-26',
    ],
    'url' => route('hr-pro.leaves.show', $leave),   // optional
]);

The same call over HTTP for an External app: POST /api/apps/notifications/dispatch with {key, payload} behind the notifications.dispatch.send scope; the answer is 202 with delivery_ids (one delivery row per channel). Details in the API reference.

Payload shape — these keys are reserved; any other top-level key is moved under variables for you:

Key Type What it does
recipient {type, id} The only address the platform reads. parent_of_student (id = the student's ulid — the platform resolves the active guardians), host_user (a user id or ulid), school_admin (no id — the school's admins).
variables map What the template's {{ placeholders }} resolve to.
subject / body string Ready-made wording, used only when the school has no custom text and you shipped no default_template.
url string Where the notification opens (in_app / push).
locale string Display locale when it differs from the school's.
metadata map Stored on the delivery row, never shown.

The rule that cost the most: a student_id or user_id beside the variables is a template variable, not an address. A payload that relies on it arrives unaddressed and falls back to the recipients the school configured on the notification row (its admins by default) — so the office receives what was meant for a parent, or the delivery row reads failed: No … recipients when nothing is configured. Therefore every key whose audience includes guardian or student always carries a recipient; only an office-wide key (audience: admin — day closed, weekly digest) may go out unaddressed to whoever the school configured. The scanner warns (non-blocking) when a literal payload for such a key has none (notification-recipient-missing).

The text a person reads is chosen by the platform in this order: the school's custom wording → your default_template → body in the payload → a stand-in built from the notification's name. Invest in default_template, not in body.

When push actually arrives: when push is enabled on the school's row, and the platform has an FCM account configured, and the recipient has a device registered from okta-app. Any missing piece does not throw — the delivery row reads skipped_no_transport or failed with the reason (see "Local testing" below).

partner_notify() does not exist on the platform. An older edition of this guide advertised it; the call fails at runtime unless your module defines the helper itself, and the scanner flags it (notification-helper-undefined).

A notification you can act on: recipient and url

The payload is not only template variables. Two reserved keys are read by okta-web before anything reaches a template, and they are the difference between a notification that gets read and one that gets acted on:

Key Shape What it does
recipient { type, id } Who. Overrides the tenant's configured recipient list, because this notification is about one person, not a broadcast.
url string The action. The platform turns the notification card into a link to it.

recipient — the actual address

partner_notify('leaves.request.submitted', [
    'student_name' => $student->name,
    'recipient' => ['type' => 'parent_of_student', 'id' => $student->ulid],
]);

Types that resolve:

  • parent_of_student + id = the student's ULID → their active guardians at this tenant (link not withdrawn, guardian not released). This is the address most school notifications want, and note it is the student's ULID, not the guardian's: your app knows the student, the platform knows who belongs to them.
  • host_user + id = a user ULID → that user only.
  • school_admin (no id) → the tenant's administrators.

With no recipient — or a type the platform doesn't know — routing falls back to whatever the tenant configured under /settings/notifications (roles plus named users). That is right for a broadcast ("exam timetable published") and a silent bug for a personal event: "your child asked to leave early" then reaches the tenant's whole list and never reaches the parent.

The catalog's audience is not routing. It is a declaration the tenant reads before enabling the notification ("aimed at guardians"). Routing happens here, per dispatch.

url — what the reader opens

The notification card — in the notifications centre and on the guardian portal's first screen — becomes a link when the payload carries url, and stays a plain card without one:

partner_notify('leaves.request.submitted', [
    'student_name' => $student->name,
    'recipient' => ['type' => 'parent_of_student', 'id' => $student->ulid],
    'subject'   => 'A leave request is waiting for you',
    'body'      => "{$student->name} asked to leave at 11:30",
    'url'       => route('leaves.requests.show', $request),   // ← the action
]);

Point it at the screen where the thing gets done, not your app's home page: a parent who is told "waiting for your approval" and then dropped on a dashboard has to find the child and then the request — the exact trip the notification existed to remove. A relative URL (/apps/leaves/requests/42) is enough, and preferred.

The card's text

The title comes from the catalog (display_name_ar/display_name_en by the reader's locale), never from the payload — which is why the Arabic name is mandatory. The body is read from payload.body, then payload.subject, falling back to the catalog description when the app sends neither. Send a written body: a catalog description is generic by nature ("sent when a leave request is submitted") and names no child and no time.

Two conditions before the card exists at all

  1. in_app is declared in default_channels for that key — a tenant can narrow your channels, never widen them.
  2. The tenant has not switched the channel or the notification off under /settings/notifications.

To see what actually happened: mcp__okta__delivery_stats and webhook_deliveries read the delivery rows, and a skipped_disabled row means the tenant turned it off — not that your dispatch failed.

The scanner

scripts/partner-policy/NotificationScanner.php collects every "used" key from the dispatch calls — app(DispatchNotification::class)('key', …), the fully-qualified spelling, and partner_notify('key', …) (matched so its key counts, and flagged because the function is not on the platform) — reads the declared keys and their audience from manifest.json, and compares:

  • Used but not declared → blocking violation (fails CI).
  • Declared but not used → warning (non-blocking).
  • partner_notify() in code → warning notification-helper-undefined.
  • Guardian/student key dispatched with a literal payload and no recipient → warning notification-recipient-missing — it names the payload that would reach nobody before a school does.

Run locally:

php scripts/partner-policy/check.php Modules/

Manifest contract

{
  "notifications": [
    {
      "key": "hr-pro.leave_request.approved",
      "display_name": { "ar": "تمت الموافقة على طلب الإجازة", "en": "Leave request approved" },
      "description": { "ar": "يُرسَل تلقائياً ...", "en": "Sent automatically ..." },
      "variables": { "employee_name": "string", "leave_days": "int" },
      "default_channels": ["email", "in_app"],
      "severity": "info",
      "is_active": true
    }
  ]
}

okta-web's SyncCatalogFromManifest reads this block on publish and upserts the rows into the platform's notifications table.

Don't edit this block by hand in code. The Notifications tab writes it for you.

Who controls which channel

  • Partner declares the possible channels in default_channels.
  • Tenant enables a subset and picks recipients per installation.
  • okta-web transports the message via the enabled channels.

Your app says dispatch(key, payload) without worrying about delivery. okta-web handles the entire fan-out (email via Mailable, SMS via provider, WhatsApp via template API, push via Web Push/FCM, in_app writes to notifications table, webhook_out POSTs an HMAC-signed envelope). Your app writes no per-channel transport classes.

Worked example: HR app

1. Declare in the dashboard

Key Default channels Severity
hr-pro.leave_request.submitted in_app, email info
hr-pro.leave_request.approved email, whatsapp, in_app info
hr-pro.leave_request.rejected email, in_app warning
hr-pro.attendance.alert email, in_app critical

2. Dispatch from code

namespace Modules\HrPro\Services\LeaveRequests;

use App\Services\PartnerApi\Notifications\DispatchNotification;

final class ApproveLeaveRequest
{
    public function __construct(private readonly DispatchNotification $notify) {}

    public function __invoke(LeaveRequest $request, int $approverId): LeaveRequest
    {
        $request->update(['status' => 'approved', 'approved_by' => $approverId]);
        ($this->notify)('hr-pro.leave_request.approved', [
            'recipient' => ['type' => 'host_user', 'id' => $request->employee_user_ulid],
            'variables' => [
                'employee_name' => $request->employee_name,
                'leave_days' => $request->days_count,
                'start_date' => $request->starts_at->toDateString(),
            ],
        ]);
        return $request->fresh();
    }
}

3. Submit, approve, publish. Tenants now see the notifications

in /settings/notifications.

Tips

  • Name keys by domain, not technology. hr-pro.leave_request.approved × hr-pro.email.sent.
  • default_channels is a ceiling, not a default: schools narrow it and can never widen it, so a channel you leave out can never be enabled by anyone. Declare every channel the notification legitimately fits (in_app and push for anything a parent reads in the app, whatsapp/sms for what deserves a paid message) — the row is seeded disabled at the school anyway, and the paid channel remains its decision and its provider.
  • Use severity=critical sparingly — it breaks quiet-hours.
  • Keep variables_schema small and meaningful.
  • Use is_active=false instead of delete on a published version.

Common errors

Error Fix
notification-key-not-declared Declare on the tab, pull manifest.
notification-key-unused (warning) Remove key or wire the dispatch.
cannot_edit_published Create new version, catalog auto-cloned.
delete_blocked_by_installs Use is_active=false instead.
key_prefix validation error Type only the suffix after <slug>..
Notification key [x] is not declared in this app's catalog (exception / 422) Declare the key and publish a version carrying it; make sure the school installed that version. Also raised when the key is is_active=false and the school never configured it.
DispatchNotification called outside a partner-app context Build the context around the work — BootModuleContext is middleware and lives as long as the $next it is handed.
Delivery log: failed: No … recipients Add recipient: {type: parent_of_student, id: <student ulid>} — a student_id beside the variables is a template variable, not an address.
Delivery log: skipped_disabled Nothing in code — the school enables the key on /settings/notifications; say so in your app's guide.
Delivery log: skipped_no_transport The channel has no transport on the platform (push without FCM) — nothing in code.
notification-helper-undefined (warning) Replace partner_notify() with app(DispatchNotification::class)('<key>', $payload).
notification-recipient-missing (warning) Add recipient; a payload built in a variable is not judged.

Local testing

Dispatching needs an installation context, not a tinker session: DispatchNotification reads the school and the installation from the ModuleContext that the module.context middleware builds on your module's routes, and refuses outside it with «outside a partner-app context». So test from inside the app on the sandbox, one of two ways:

  • From a screen of your module (a route behind module.context), or from a queue job / mobile endpoint that builds the context itself through BootModuleContext — wrapping the work inside it, not calling it and working afterwards.
  • Or with an HTTP request carrying the installation token (External apps, or any app from a terminal):
curl -X POST "<sandbox base url>/api/apps/notifications/dispatch" \
  -H "Authorization: Bearer <installation token>" \
  -H "Content-Type: application/json" \
  -d '{"key":"hr-pro.leave_request.approved","payload":{"recipient":{"type":"host_user","id":"<user ulid>"},"variables":{"employee_name":"Sara","leave_days":3}}}'

Then read what happened — three places show the same delivery row with its status and error text:

  • The school's page /settings/notifications → the "Delivery log" card under the table (recent days: every attempt with its channel, status and the error the channel job left). The same page has a test-send button per key that renders your template with dummy values and really sends it to a recipient the operator picks — the fastest way to learn that FCM is configured and the device registered.
  • From your app: GET /api/apps/notifications/logs (scope notifications.logs.read) with channel / status / key / from / to filters.
  • From your assistant: the MCP tool notification_deliveries {slug} — totals by status, channel and key across every school that installed your app, then the latest failures with their text.

Statuses you will see: sent / queued delivered or waiting; failed with the reason — No … recipients specifically means a payload without recipient; skipped_disabled the school has not enabled the key; skipped_quiet quiet hours; skipped_no_transport a channel with no transport on the platform (push without FCM); skipped_plan_feature the school's plan does not include app notifications.


Cross-app access (your app needs another app's data)

The schedule app needs to know who was absent today. Attendance data lives in the attendance app's own database — a separate schema nothing else reaches. So the question is not "how do I read that table", it is "how do I ask the attendance app's developer to open a specific part of their data to me".

The answer is two consents, not one:

Who consents To what Where
The developer who owns the data "this app may ask" /dashboard/app-links
Every school installing both apps "yes, read OUR pupils' data" the app's store page

The second has existed in okta-web all along. The first is what this section is about, and it cannot be skipped: an access declaration with no developer approval behind it does not publish.

1. If you own the data: declare what you share

Nobody can ask you for something you have not declared. In the app (or version) editor → "Cross-app access" → "What this app shares":

"provides": [
  {
    "resource": "records",
    "access": "read",
    "label": "Daily attendance records",
    "description": "A pupil's presence, absence and lateness, by date and period.",
    "reader": "Modules\\Attendance\\Services\\PartnerApi\\RecordsReader"
  }
]
  • resource — lowercase and _ only, up to 40 characters. This is the key a school's consent is stored under in okta-web, so renaming it later cuts every existing consent loose from its meaning.
  • access — read or write. Not two rungs of a ladder: offering read does not offer write, and the reverse is equally untrue. Declare two rows if you mean both.
  • label and description — written for the school, not for a programmer. This exact text is what appears on the store's consent screen. "Daily attendance records" can be read and decided on; att_rec_v2 cannot.
  • reader — the field that makes the offer yield anything. Everything else is consent paperwork establishing that a link may exist; this class is what the platform actually runs to produce the rows. Without it a school can grant the row and it returns nothing. It must live under Modules\ in your own code and implement the interface — see below.
  • writer — the counterpart of reader on an access: write offer: the class that accepts what the other app sends, validates it and stores it by your rules. A write offer with no writer is a consent that stores nothing. See "Writing into another app's data" below.

Write the reader

namespace Modules\Attendance\Services\PartnerApi;

use App\Services\PartnerApi\Contracts\CrossAppReader;
use Modules\Attendance\Models\AttendanceRecord;

final class RecordsReader implements CrossAppReader
{
    public function read(string $resource, array $filters, string $consumerSlug): array
    {
        return AttendanceRecord::query()
            ->when($filters['from'] ?? null, fn ($q, $d) => $q->whereDate('date', '>=', $d))
            ->limit(200)
            ->get()
            ->map(fn ($r) => [
                'student_ulid' => $r->student->ulid,
                'date' => $r->date->toDateString(),
                'status' => $r->status,
            ])
            ->all();
    }
}

Three rules that are not negotiable:

  • Do not re-check permission here. The platform calls your class only after establishing all three: that the consuming developer was granted this pair, that this particular school consented to it, and that the caller is $consumerSlug — taken from the active context, never from anything the caller sent. A second check adds complexity, not safety.
  • $filters is untrusted input. It comes from another app. Validate it as you would a $request, and cap the row count.
  • Return plain values, and ULIDs rather than numeric ids. The caller is a different codebase possibly deployed at a different version; handing back an Eloquent model couples the two products' schemas, and any later column rename becomes a cross-partner breaking change.

The class runs inside your context: your schema, your approved scopes, the current tenant. Scope your query to the current tenant exactly as you do on your own pages.

Embedded only. The reader is a PHP class living in code you ship inside okta-web. External apps can declare provides and appear on the consent screen, but have no read channel yet.

Only what is published is visible. Other apps see provides from your latest published version only. While it sits in a draft it does not exist for them — deliberately: a link is a commitment between two shipping products, and building one against a draft means what you depend on can change or vanish before anyone outside your team ever saw it.

Document what you share

label and description are one sentence each, for the school. The developer building on your offer needs what the manifest has no room for: the shape of the rows a reader returns, the filters it understands, the payload a writer accepts and what it returns, an example call. That is what shared-data docs are: one Markdown page per app, written from the app editor ("Document what you share" on the "What this app shares" card) or from the MCP assistant:

set_shared_docs {
  "slug": "attendance",
  "body": "## records (read)\n\nEach row: `student_ulid`, `date` (YYYY-MM-DD), `status` (present|absent|late).\n\nFilters: `from` start date. Capped at 200 rows.\n\n```php\napp(ReadFromApp::class)('attendance', 'records', ['from' => '2026-09-01']);\n```\n\n## records (write)\n\nPayload: `student_ulid`, `date`, `status`. Returns `{id, status}`."
}

Who reads it: your team, and the developers of apps whose link request to your app you approved — after approval, not before. A pending request reads nothing; a revoked one no longer does. Not schools, not the public, so it can carry the field-level detail a consent card cannot. And it sits outside the manifest: an edit reaches the reader at once, with no new version.

2. If you need the data: send the request

/dashboard/app-links → "New access request". Pick your app, then the app that owns the data, then — from its published list — what you need, then write your reason.

The reason is not a formality: it is everything the other developer reads before deciding. Say exactly what you will show the user ("we show absence periods inside the pupil's schedule, nothing stored") rather than something generic ("for integration").

3. The reply: all, part, or refusal

The owning developer sees it under "Incoming" and can:

  • Approve everything — the common case, one click.
  • Approve part of it — "read the monthly summary, not the detailed register". That is the normal answer to a real request, not an edge case.
  • Refuse — with a reason, required. A bare "no" leaves the other developer with nothing to change and no way to know whether asking again could ever work.

Approval needs no explanation; refusal does. After approving, a developer may still revoke (also with a reason) — after which your app can no longer declare that access in future versions.

Once approved, the provider's docs open to you: the "Docs" button on the link in the app links page, or get_shared_docs {"slug": "attendance"} from the assistant. Read them before writing a line of ReadFromApp or WriteToApp; the manifest carries only a name and a sentence per offer.

4. Declare the dependency in your version

Once approved, the row appears in your app editor under "Data this app needs". Do not hand-write it — the list is built from live grants, so anything not in it cannot be declared at all. For each row you choose two things:

"cross_module_access": [
  {
    "module": "attendance",
    "resource": "records",
    "access": "read",
    "required": true,
    "reason": "To show absence periods inside the pupil's schedule."
  }
]
  • required — the field the school actually feels:

    • true → the store asks it to install both apps together, and your app does not work without it.
    • false → the rest of the app works and only this feature degrades.

    Get it backwards and a school is left unable to use what it paid for. Use true only when the app is meaningless without the data.

  • reason — appears verbatim on the store page next to the consent control. Write it for the school.

What happens at install — and on every update

okta-web reads cross_module_access from your manifest and creates one row per entry in module_cross_access_grants, ungranted (is_granted = false). The store page then shows the card to the school, which grants or refuses each row itself. No data moves before that.

The rows are created on every update too, not only on first install. If you add the dependency in a later version (say 2.3.17), every school that installed your app before it gets the missing rows the moment the version is published, and the card appears for them with its switches — no reinstall. Existing rows are never touched: what a school granted stays granted, what it refused stays refused.

A developer's approval is not a school's. It means only "this app may ask". Every school is asked separately afterwards, and may refuse.

And who at the school answers? Whoever holds "Approve data sharing between apps" (app_store.cross_access.approve) through one of their roles — granted to the entity-admin role by default, and the school may hand it to anyone else. Someone opening your app's page without it sees the state and cannot change it. If a school tells you the feature does not work and "nobody can approve", that is the first question to ask: who there holds it.

What the release gate checks — and what it does not

The platform checks your cross_module_access declaration at three moments, and does not check the same thing at each:

Moment What is checked Why
Saving in the editor Every entry, against the provider's published catalogue (and on the sandbox, what your sibling app installed there) This is where you can act — drop a line, or ask for a new approval
Submitting for review and publishing Only what is new: entries not in your currently published version's declaration, keyed by (module, resource, access) What shipped was checked when it shipped; re-checking it against the provider's current catalogue was stalling your release for somebody else's stumble
Republishing Nothing A republish pushes what already shipped and adds no line

The story that forced the rule: an app declares a read of attendance_status from "Attendance", ships, schools consent, reads flow. Then "Attendance" publishes a version that accidentally dropped its provides — and republishing the first app is refused with «"Attendance" does not publish okta_hdor.attendance_status.read in its latest published version», while the version installed at every school does publish it. The app was punished for a change it did not make, and the punishment stalled its whole release pipeline — a fix unrelated to sharing could not get out.

Why the rule is safe: a school does not need the gate to protect it from a line that already shipped. The runtime refuses the read anyway when the provider no longer publishes the resource or the school never consented (ReadFromApp), and drops a broken offer from the catalogue. The gate at publish protects against a new line shipping without a developer approval or a published offer — which is exactly what it still does.

What this means for you:

  • "does not publish … in its latest published version" at save time, on a line that used to work, means the provider changed its catalogue. Contact its developer; your published version is unaffected, and your next release is not blocked by that line.
  • The same message at publish means a new line in this version: the provider does not publish what you ask for, or has not approved it yet. That is a correct refusal, and there is no way around it.
  • write beside a live read is a new line — two consent rows, not two degrees of one.

Writing into another app's data

The channel was a read channel by shape, and this guide used to say that write was declared, shown to the school, and not enforced. That is no longer so: writes have their own door, and the word on the consent card decides which door opens.

On the data owner's side — an access: write offer names a writer:

{
  "resource": "records",
  "access": "write",
  "label": "Record attendance from another app",
  "description": "Lets another app record a pupil as present, absent or late for the day.",
  "writer": "Modules\\Attendance\\Services\\PartnerApi\\RecordsWriter"
}
namespace Modules\Attendance\Services\PartnerApi;

use App\Services\PartnerApi\Contracts\CrossAppWriter;
use Illuminate\Support\Facades\Validator;
use Modules\Attendance\Models\AttendanceRecord;

final class RecordsWriter implements CrossAppWriter
{
    public function write(string $resource, array $payload, string $consumerSlug): array
    {
        $data = Validator::validate($payload, [
            'student_ulid' => ['required', 'string'],
            'date' => ['required', 'date'],
            'status' => ['required', 'in:present,absent,late'],
        ]);

        $record = AttendanceRecord::query()->updateOrCreate(
            ['student_ulid' => $data['student_ulid'], 'date' => $data['date']],
            ['status' => $data['status'], 'source' => $consumerSlug],
        );

        return ['id' => $record->ulid, 'status' => $record->status];
    }
}

The reader's three rules apply, plus one: $payload is input from another app — validate it as you would a $request, and refuse bad input by throwing, never silently; a writer that returns without error for something it never stored leaves the other app believing it wrote.

On the consumer's side — one call, shaped like the read:

use App\Services\PartnerApi\CrossApp\WriteToApp;

$written = app(WriteToApp::class)('attendance', 'records', [
    'student_ulid' => '01J…',
    'date' => '2026-09-13',
    'status' => 'absent',
]);
// ['id' => '01K…', 'status' => 'absent']

The call checks what the read checks, with one difference you must know: the school's consent to a write is a separate consent. read and write are two rows in module_cross_access_grants and two lines on the card, and okta-web now compares the access column: a read consent does not open WriteToApp, and a write consent does not open ReadFromApp. An app that needs both declares two entries in cross_module_access and the school is asked about each on its own.

On a sandbox tenant, two apps of yours read each other directly: no approval from another developer (there is none), no consent from the entity (it is yours), and no production publish. What is judged is what you have installed on the sandbox: install the providing app there with a version whose provides declares the resource and names a reader, then call ReadFromApp from the other app and the data comes back. The consent card in the sandbox store shows the row with a "granted automatically" badge instead of a switch.

In the version editor, resources of your other apps that are installed on the sandbox are declarable even before they are published, with a "sandbox only" badge. That is enough to save and to try, and not enough for review: submitting the version for review returns to the original rule — the other app must have published what you depend on, because a production school cannot be asked to consent to a resource that was never shipped to it.

The rule never applies on a production tenant, even when both apps are yours: a school's data is the school's, and the granted row is the only permission there.

Worked example: a write between two of your apps on the sandbox, nothing published

The case: an "attendance" app owns attendance records, and a "gate" app wants to mark a pupil absent when their card is swiped. Both are yours; neither is published yet. The steps from the MCP assistant (or the editor — both write the same columns):

1. The provider declares a write offer with a writer — on attendance's editable version:

set_shared_data {
  "slug": "attendance",
  "shares": [
    {
      "resource": "records",
      "access": "write",
      "label": "Record attendance from another app",
      "description": "Lets another app record a pupil as present, absent or late for the day.",
      "writer": "Modules\\Attendance\\Services\\PartnerApi\\RecordsWriter"
    }
  ]
}

Then write the RecordsWriter class as in "Writing into another app's data" above, and push it.

2. The provider is installed on the sandbox — install_on_sandbox {"slug": "attendance"} (or the install button on the Sandbox tab). This is what is judged: the version installed on the sandbox, not the latest published one.

3. The consumer declares the dependency — the offer now appears in get_app_dependencies {"slug": "gate"} under "available to declare", marked "(sandbox only)":

set_app_dependencies {
  "slug": "gate",
  "dependencies": [
    {
      "module": "attendance",
      "resource": "records",
      "access": "write",
      "required": false,
      "reason": "To record a pupil's absence automatically when their card is swiped at the gate."
    }
  ]
}

The save is accepted because both apps are yours and the provider is installed on the sandbox. (Were "attendance" another developer's, the row would be refused until that developer approved and published the offer.)

4. The consumer is installed on the sandbox and writes — install_on_sandbox {"slug": "gate"}, then from the gate's code:

use App\Services\PartnerApi\CrossApp\WriteToApp;

$written = app(WriteToApp::class)('attendance', 'records', [
    'student_ulid' => $student->ulid,
    'date' => today()->toDateString(),
    'status' => 'absent',
]);

No consent row is waited on: on a sandbox tenant, with both apps by one developer, the call goes straight to RecordsWriter inside attendance's context. The consent card in the sandbox store shows the write row with a "granted automatically" badge.

5. Before review — publish "attendance" first (so the write offer is published), then submit "gate" for review. In the reverse order gate is refused with "does not publish", and that is the right refusal: a production school will be asked about a write row, and there must be a published offer behind it.

The only thing that changes on production afterwards: the school's consent to the write row becomes the permission, asked about separately from any read row.

Actually reading the data

One call, from inside your app:

use App\Services\PartnerApi\CrossApp\ReadFromApp;

$records = app(ReadFromApp::class)('attendance', 'records', ['from' => '2026-08-01']);
// [['student_ulid' => '01J…', 'date' => '2026-08-16', 'status' => 'absent'], …]

Three arguments: the app that owns the data, the resource exactly as it declared it in provides, and optional filters passed through to its reader. You get back whatever the reader returned — plain arrays and scalars.

You do not pass your own identity. No $tenantId, no slug for your app: both are read from the context you are already running in. If the caller's slug were an argument, any app could claim to be another and borrow its links.

What the call checks before returning anything

  1. The caller is the active module context, not something you sent.
  2. The school consented to this exact row — (requester, provider, resource) — the card it saw in the store. The developer's approval alone is not enough.
  3. The provider still publishes the resource and named a reader for it, in the manifest of the installed version — not in the row you were granted on approval day. Withdraw a resource and you stop answering for it.

The platform then enters the provider's context and calls its reader there, so it reads its own data with its own credentials, and your context is restored on the way back. You never touch its schema and learn nothing about it — by design: every install owns its own Postgres schema behind a restricted role, so reading directly is not merely forbidden, it is impossible.

A refusal is an exception, not an empty array

A row the school did not grant throws a RuntimeException whose message names which gate closed; it does not return []. "There is nothing to read" and "you are not allowed" are entirely different states, and conflating them makes you ship a blank panel instead of a fixable error.

Which means you expect the exception and treat it as ordinary on a required=false row:

try {
    $records = app(ReadFromApp::class)('attendance', 'records');
} catch (\RuntimeException) {
    $records = null; // render the schedule without the absence column.
}

On a required=true row it is only what happens between install and consent, and does not recur afterwards.

Why not CrossModuleAccessService::hasAccess()? This guide used to point you at it, and it was wrong twice over: the policy scanner rejects the import (internal-service-import — App\Services\PartnerApi\* is the whole contract), and it returns a boolean anyway, telling you that you were allowed to read while giving you no way to read. ReadFromApp checks the same thing and then returns the data.

Limits worth knowing

  • write is enforced. okta-web compares the access column: a read consent opens reads only, a write consent opens writes only. An app that relied on a read row passing a write check (which this guide used to describe) now needs a write offer with a writer on the provider, a write entry in its own declaration, and the school's consent to that row.
  • Revocation is not retroactive. If a developer revokes, your future versions stop being able to declare the access, but schools that already granted it keep it until the platform is updated.
  • Your own apps need no request. Two apps of one partner have nothing to negotiate — declare the dependency directly. On the sandbox it is enough that the other app is installed there with the resource; at review it must still have published what you name in its provides.

Printing on the school's letterhead (report templates)

A template is a letterhead, not a document. The school owns the frame — its logo, header, footer, watermark, margins, paper size and orientation — arranged once in Okta's report builder. Your app supplies the body; a template carries no content of its own.

Why not print it yourself: every app that prints anything meets the same wall — Arabic shaping, RTL, a font that renders it, page margins, and a header the school recognises as its own. Solved separately in each app it is solved differently in each app, and the school ends up holding six documents in six layouts, none of them its letterhead.

Scope: reports.builder.read — the same scope as the report catalog, and all most apps need. Authoring one is a separate act behind a separate grant, reports.builder.write; see Proposing a letterhead below.

1. List the letterheads

GET /api/apps/reports/templates

Each entry carries id, names, paper/orientation, and variables — the fields that particular template asks for at print time. Read them: a header reading رقم الخطاب: {{letter_no}} prints a blank there if you send nothing, and the document will not tell you why. required is your cue to ask a person rather than guess.

id is an opaque string — pass it back exactly as received; it is not a ULID, unlike other ids on this surface. Only APPROVED templates are listed: a school mid-redesign has a draft that must not reach a parent, and your app cannot tell a finished design from an unfinished one by looking at it.

2. Print

POST /api/apps/reports/templates/{id}/render
{
  "blocks": [
    { "type": "heading",  "text": "كشف الدرجات", "level": "h1" },
    { "type": "info-row", "label": "الطالب", "value": "أحمد صالح" },
    { "type": "table", "headers": ["المادة", "الدرجة"], "rows": [["الرياضيات", "95"]] }
  ],
  "variables": { "letter_no": "1447-42" }
}

The response is the PDF itself (application/pdf), not a link: a stored file needs a lifetime, a cleanup policy and an access rule, for a document that may carry a named child's marks.

Block types: heading (text/html, level, align), text (html), info-row (label, value), table (headers, rows, striped), divider, spacer, image (path on Okta's public disk). An unknown type is refused, not skipped — a silently dropped block yields a document short by a whole section that says nothing about it. You get 422 naming the block and its type.

Variables: an undeclared key is dropped rather than refused (unlike a block type — a stray key costs the page nothing), platform variables ({{tenant.name}}, {{date.today}}, {{page.number}}) cannot be overwritten, and anything you omit falls back to the school's default.

Approval is re-checked at render, not only at listing: a stored id outlives the listing that handed it out. If you cache a template id, treat 422 on render as ordinary — re-list and let the user choose.

Embedded apps call the services in-process instead, with no HTTP and no token:

$templates = app(\App\Services\PartnerApi\Reports\Templates\ListReportTemplates::class)();
$pdf = app(\App\Services\PartnerApi\Reports\Templates\RenderReportTemplate::class)(
    $templates[0]->id,
    [['type' => 'heading', 'text' => 'كشف الدرجات', 'level' => 'h1']],
    ['letter_no' => '1447-42'],
);
// ['filename' => ..., 'mime' => 'application/pdf', 'bytes' => ...]

Proposing a letterhead

All of the above assumes the school already arranged a letterhead that suits your document. Often it has not: a marks app needs a sheet with the term in the header and two signatories at the foot, and the school does not know that is what it needs until it sees one.

Scope: reports.builder.write — deliberately separate, so the school is asked a second question at install rather than every app holding .read quietly gaining the ability to put drafts in front of them.

Four things the grant does not buy, and none of them bends:

  1. It arrives as a draft and does not print. A template you just authored is absent from GET /reports/templates and POST .../render answers 422 for it. Not an error to work around: everything printed on a letterhead goes out in the school's name, and an app that could author and print in one step could issue documents nobody at the school ever saw.
  2. Only what you authored. The school's own letterhead, and one authored by another app on the same school, both answer 422 on update or delete. (Tenant isolation says nothing here — both apps are legitimately installed.)
  3. An edit revokes approval. Editing a template the school approved sends it back to draft and it stops printing until approved again. That is the cost and it is the point: an approval that survived an edit would let an app get one innocuous design signed off and then write anything into it.
  4. An approved one is no longer yours to delete. Approval is the school taking the design as its own — it may be on a term's paperwork. Deletion answers 422; the school removes it from its own builder, where the consequence is visible to the person choosing it.
POST   /api/apps/reports/templates        → 201, the template as the listing describes it
PATCH  /api/apps/reports/templates/{id}
DELETE /api/apps/reports/templates/{id}
{
  "name_ar": "كشف درجات",
  "name_en": "Marks sheet",
  "header_center_content": "كشف درجات — الفصل {{term}}",
  "signatures_enabled": true,
  "signatures": [
    { "title_ar": "معلم المادة" },
    { "title_ar": "مدير المدرسة" }
  ],
  "variables": [
    { "key": "term", "label_ar": "الفصل الدراسي", "required": true }
  ]
}

Writable fields are an allow-list: identity (name_ar required, name_en, description, type), paper (orientation, paper_size), header_enabled/footer_enabled and the header_*_content / footer_*_content slots (right/center/left), watermark_text and watermark_opacity, the four margin_*_mm, the signature block (signatures_enabled, signatures_columns, signatures_title, signatures[]), and variables[]. Anything absent is not writable — notably header_logo and background_image (a school's mark on its own paper, and the file would arrive from outside the school entirely) and the approval state (writable, an app would approve its own draft and every guarantee above would be ornamental).

The logo and entity name are inherited, copied from a letterhead the school already approved — a proposal that arrived blank-headed would read as a broken app rather than a draft awaiting their logo.

On PATCH every field is optional and omission means "leave it alone", never "clear it", so an app that forgets a key does not blank a school's footer. The exception is variables and signatures, where an explicit empty array means "remove them all".

A custom variable key that shadows a platform one (tenant.name) is dropped silently — the same rule the school's own editor goes through, so it cannot drift between the two ways in.

25 templates per installation. An app proposes a handful of documents it knows how to fill; a buggy loop proposes thousands, and the school finds out as a builder page it can no longer read. Over the cap answers 422.

Uninstalling deletes nothing. Templates you proposed stay with the school — reviewed, approved, printed on; they are the school's documents now, whoever drafted them. They only lose their attribution. So do not rely on a reinstall restoring your authorship: a new installation owns nothing a previous one authored.

Propose once at first run, not on every screen open, or the school's board fills with identical drafts awaiting an approval that will not come. List first and propose only when nothing fits — a letterhead the school arranged by hand always wins. And do not poll for approval: nothing on this surface reports when it happens, the decision can take days, and listing at the moment the user needs a template shows only the approved ones anyway.

Embedded apps use CreateReportTemplate, UpdateReportTemplate and DeleteReportTemplate in the same namespace.


Security

Embedded

  • ✓ Policy scanner (regex + PHPStan AST) on every PR.
  • ✓ Postgres role/schema isolated from the platform.
  • ✓ BlocksPartnerDirectAccess trait refuses access outside App\Services\PartnerApi\*.
  • ✓ Every scope checked at runtime via AppPermissionGuard.

External

  • ✓ Installation token encrypted at rest.
  • ✓ Webhooks signed + timestamped + replay-protected.
  • ✓ HTTPS-only webhook_url.
  • ✓ Okta-side rate limit (60 req/min per installation).

Your responsibilities

  • Never log the installation token.
  • Store webhook_secret in a secret manager.
  • Apply your own rate limits if forwarding tenant data.
  • Don't store tenant data beyond what you need.

Local testing

Sandbox tenant

Every Embedded app gets a sandbox tenant with seeded data. Free, unlimited.

Webhook tunneling

For External apps use ngrok:

ngrok http 8000

Paste the URL into the sandbox app's webhook_url. Replay any past delivery from "Webhook Deliveries → Replay".

Postman / Insomnia

Import https://partners.getokta.io/docs/openapi.json or the Postman collection. Paste a sandbox token into the {{token}} variable.


AI-assisted development (MCP)

The platform ships two MCP (Model Context Protocol) servers that connect your coding assistant (Claude Code, Cursor, or any MCP client) to the Okta standards and tooling — so your assistant follows the platform rules from the first line instead of discovering violations at PR review.

1) Local server — inside your repo (no account needed)

Every app repository scaffolded by the platform ships a ready local MCP server in scripts/mcp/ with a root .mcp.json:

  • Claude Code: auto-discovers it when you open the repo — zero setup.
  • Cursor: add to .cursor/mcp.json:
{ "mcpServers": { "okta-partner": { "command": "php", "args": ["scripts/mcp/server.php"] } } }

Requires only php on your PATH (no Composer, no network). Its tools wrap the exact CI scanners (scan_code, validate_manifest, lint_permission, component_catalog, feature_service, search_standards, list_scopes) — whatever passes locally passes the merge gate. See scripts/mcp/README.md in your repo.

The hosted server adds live, identity-bound tools on top of the standards: your apps and versions, the live scope catalog, monitoring (app health and webhook deliveries), the app-preview simulator, and control / app-control-panel tools.

Connect (one-time):

# Claude Code
claude mcp add --transport http okta-partner https://partners.getokta.io/mcp
// Cursor — .cursor/mcp.json
{ "mcpServers": { "okta-partner": { "url": "https://partners.getokta.io/mcp" } } }

The first use opens your browser on the partner platform's consent screen: sign in with your normal account, review what the tool will be able to do, and approve. No token is ever copied or pasted — the grant is full OAuth (PKCE).

Write access: by default the link is read-only (your apps + standards + monitoring). To enable the control tools (submit for review, create a version, edit the changelog and release channel, set the account types, program the app control panel and webhook settings) tick "Allow write (app control)" on the consent screen. All writes go through the same guards as the portal UI (no bypassing publish state, no writing to a published version).

Key live tools:

Tool Purpose
whoami / list_my_modules your identity and your tenant's apps
get_module / list_versions app details + built manifest + versions
module_health health report: status, versions, deployment checks, sandbox
webhook_deliveries / delivery_stats / replay_delivery External delivery history, stats and replay
notification_deliveries what became of your app's catalog notifications across every school that installed it: totals by status, channel and key, then the latest failures with their error text — the first place to look when a school says a notification never arrived
preview_module / store_listing store-card & dashboard-placement simulation + preview page URL
create_module create a new draft app — the first step, before create_version (write access required)
submit_module / create_version / update_changelog / set_release_channel / sync_from_manifest lifecycle control (write access required)
get/set_developer_ui / get/set_webhook_config program the app control panel & settings (write access required)
set_landing_widgets declare your embedded app's landing-page widgets directly — or as a per-version override — without going through manifest.json. The list you pass replaces what is stored; [] clears the declaration (on a version that drops the override, restoring inheritance). The rules are okta-web's own publish rules (write access required)
get/set_notification_config read and write the notification/payment provider block — at module level or as a per-version override (writing requires write access)
list_notifications / create_notification / update_notification / delete_notification the catalogue of notifications a version dispatches (writing requires write access)
discover_notifications / import_notifications scan the repo for undeclared notification keys and import them (importing requires write access)
list_account_types / get_account_types / set_account_types the account-type catalog, what your versions declare, and setting it (write access required)
list_shared_data what other apps share with other apps — the list you pick from before asking for access. Read from each app's latest published version, so anything still in a draft does not appear
list_app_links / request_app_access / answer_access_request / revoke_app_access cross-app links: list them in both directions, request access, answer a request, and revoke a grant you gave (writes require write access)
set_shared_data declare what your app shares with others, on an editable version — the first step, since nobody can request what you have not declared. Whole-list replace; [] clears it. Labels and descriptions are written for the school, not the developer (write access required)
get_app_dependencies / set_app_dependencies what your app needs from other apps: what it declares today, what it COULD declare (granted to you and still published), and setting it on an editable version. Whole-list replace, and no entry without a live grant behind it (setting requires write access)
get_org_profile / set_org_profile your organization and developer profile as the store shows it, plus your interface language. The account password is never readable or writable through MCP, and the logo is uploaded from the portal only (writing requires write access)
optimize_screenshots re-encode a version's stored screenshots as WebP and scale down anything wider than needed. New uploads are already converted, so this is for an older library (write access required)

Account types over MCP

The version editor's "Account types" tab is fully reachable from your assistant:

  • list_account_types — the live catalog (the same list the portal offers): each type has a key, a target (role = a tenant role, portal = a cross-tenant portal) and Arabic/English labels. A key outside the list is accepted as a custom tenant role; the portals are a closed set (student | guardian).
  • get_account_types — what your app actually declares. Account types are declared per version (there is no module-level value to inherit), so the report lists every version; pass {version} to narrow it.
  • set_account_types — a whole-list replace on an editable version, exactly like the version editor's save. Read the current list first and resend it complete with your edit; account_types: [] clears it.

Each entry: key (must equal the role name or the portal) + kind (primary | dependent) + exactly one target: roles (one role) or portal + at least one surface: web_route (a Laravel route name such as school-app.admin — not a URL and not a /path) and/or mobile_entry (in the shape the version's mobile mode requires) — and, on a native row with a mobile_entry, an optional min_contract (integer ≥ 1: this type's own host-contract floor; omit it to inherit the block default). This single declaration is what builds menu.audiences[] in okta-web and mobile.audiences[] in the Okta app, and the tool refuses up front whatever the publish-time validator would refuse.

Custom tenant roles. A role key outside the account-type catalog is allowed — tenants define their own roles — but it is declared explicitly: the audience it produces carries "custom": true in the manifest, and set_account_types warns you when it stores one. Publishing an out-of-catalog key without that marker is rejected, on purpose. The platform matches a role key as plain text against the roles users actually hold, so an invented or mistyped key matches nobody: the version publishes, the manifest looks healthy, and every page of that audience is then refused for every user by the deny-by-default audience guard. If you meant a known type, pick its exact key (list_account_types); if you really do mean a tenant-defined role, make sure the tenant has it under exactly that name. custom is never valid on a portal audience.

Security note: the token is bound to you and your tenant only — it can never see or touch another tenant's apps, and webhook secrets are never readable through any tool. You can re-link at any time; write consent is your call on every link.


Design system & UI

Embedded apps render inside the tenant dashboard alongside okta-web's native screens, so visual identity must match. Hard rules:

  1. Use existing components: <x-card>, <x-button>, <x-badge>, <x-input-field>, <x-textarea>, <x-alert>, <x-spinner>, <x-modal-card>. No custom components, no third-party UI libs.
  2. Tailwind palette for neutrals and state colours. Your own brand colour has a supported home — see "Room to design" below; what is barred is a raw hex with no dark counterpart.
  3. RTL-first: start/end, ms-/me-, ps-/pe-, text-start/text-end. Mirror arrows via rtl:rotate-180. IDs/ URLs always font-mono + dir="ltr".
  4. Card-based composition: each section in <x-card>. Card padding p-4 mobile / p-5 md:p-6 hero.
  5. Single sticky save bar at the bottom for long forms.
  6. Explicit states: empty / loading / error are first-class.
  7. Modals via wire-elements/modal exclusively. Open with wire:click="$dispatch('openModal', { component: '<slug>', arguments: {...} })". Close with $this->closeModal() or $dispatch('closeModal').
  8. Toasts via $this->dispatch('toast', message: ...).
  9. Animations come from the motion-* layer, not your own keyframes — prefers-reduced-motion is handled there once (see "Room to design").

For the full component catalog with props/slots/examples and the ready-made AI prompt that bakes in all the above rules, see commit a164484 on prod or commit eb910cf on main. Both have the expanded Design System section. The condensed version here is kept for the prod doc budget — the AI prompt content is identical in substance.

Room to design — what is actually yours

The rules above protect the foundation: the tokens, the dark mode, RTL, and the accessibility wiring on form controls. They do not mean every app has to look like the same app. This section is what is genuinely yours, and it exists because most of the reasons partners abandoned the design system were options that already existed, or should have.

Ask your assistant first: the component_catalog MCP tool lists every component with its props and examples, the motion classes, your own brand colour, and which policy rules fail a build versus which are advice.

1. The card is flexible now

<x-card> had exactly one look, so anyone who needed another abandoned the component and hand-rolled a div — losing the tokens and the dark mode to buy a shape. Now the shape comes from the component:

<x-card tone="accent" padding="none" overflow="visible" elevation="lifted">
Prop Values When
tone raised (default) · sunken · plain · accent plain = grouping with no visible box; accent = your brand colour
elevation flat · raised · lifted lifted rises on hover
padding default · tight · none none for a table, a map, a media strip that must bleed to the card's edge
radius default · lg · none
divider true (default) · false The rule under the header
overflow hidden (default) · visible For a menu that has to escape the card

All optional, and every default reproduces the old look exactly — nothing that exists changes.

2. Your own colour

The only route to a brand colour was a literal, bg-[#0F766E]. A literal has one value, so it is right in one theme and wrong in the other — and the platform cannot help, because a hex inside a class string is opaque to it.

Declare it in the manifest with both values and it becomes something the platform knows:

"brand": {
  "accent":          { "light": "#0F766E", "dark": "#2DD4BF" },
  "accent_contrast": { "light": "#FFFFFF", "dark": "#042F2E" },
  "accent_soft":     { "light": "#CCFBF1", "dark": "#134E4A" }
}

Then use it as a variable — it follows the theme the way platform tokens do:

<div class="bg-[var(--app-accent)] text-[var(--app-accent-contrast)]">…</div>
<x-card tone="accent">…</x-card>
  • Hex only (3, 6 or 8 digits). The value is written into a <style> tag, so anything else — rgb(...), var(...), color-mix(...) — is dropped rather than escaped.
  • Three roles, no more. A longer list is a second theme system, and a second theme system is how the platform's own stops meaning anything.
  • Scoped to your pages via [data-app-brand] — it never reaches the platform chrome or another app on the same screen.
  • Declared only light? Dark inherits the same value, so nothing is half-painted.
  • A malformed role drops on its own and does not take the others with it.

3. Motion

A ready layer instead of writing @keyframes:

Group Classes
Entrances motion-fade · motion-rise · motion-slide-in · motion-scale-in
Attention motion-pop · motion-shake · motion-pulse
Interaction motion-hover-lift · motion-press
Modifiers motion-slow · motion-delay-1..3
<div class="motion-stagger">
    @foreach ($rows as $row)
        <x-card class="motion-rise">…</x-card>
    @endforeach
</div>

motion-stagger on the container starts each child 40ms after the last — for ten steps, then it stops adding delay, so the tail of a long table does not arrive a second and a half late.

Why use these rather than your own keyframes: prefers-reduced-motion is handled for all of them in one place. Someone who turned motion off in their OS did not do it as a preference — for a person with a vestibular disorder, motion they did not ask for is a symptom, not decoration. Every keyframe you write yourself is one more place that has to remember them.

And retime freely: --motion-duration on any element retimes everything under it, and 0ms stops it.

4. The gate has two channels, not one

  • Fails the build — what breaks outside your design: raw <input>/<select>/<textarea>, the gray|zinc|slate|indigo neutrals, and container-scale max-w-* (3xl and wider, full, screen-*).
  • Reported, does not fail — consistency you may overrule: a raw <button>, and a hard-coded colour literal.

max-w-prose and max-w-[42ch] are not violations — that is typography, not a container. And a raw <table> is no longer a violation at all: <x-table> cannot express a timetable or a calendar, and a rule everyone overrides teaches people to override rules rather than respect them.

5. Modals

Do not build one yourself with x-show/x-data and a hand-rolled backdrop. The platform uses wire-elements/modal, and the stack is already registered in the layout — do not register it again.

1. The component extends ModalComponent, not Component:

use LivewireUI\Modal\ModalComponent;

class WithdrawalRequestModal extends ModalComponent
{
    public int $moduleId;

    public static function modalMaxWidth(): string
    {
        return 'lg'; // sm | md | lg | xl | 2xl …
    }

    public function mount(int $moduleId): void
    {
        $this->moduleId = $moduleId;
    }

    public function submit(): void
    {
        // …
        $this->closeModal();
    }
}

2. The view always uses <x-modal-card> — never a raw <header> or <footer>:

<x-modal-card class="w-[min(92vw,32rem)]" :title="__('…')" :subtitle="__('…')">
    <form wire:submit="submit" id="my-form" class="space-y-3">
        <x-input-field :label="__('…')" wire:model="amount"
            :errorMessage="$errors->first('amount')" />
    </form>

    <x-slot:footer>
        <x-button variant="primary" type="submit" form="my-form">{{ __('actions.save') }}</x-button>
        <x-button variant="secondary-subtle" type="button"
            wire:click="$dispatch('closeModal')">{{ __('actions.cancel') }}</x-button>
    </x-slot:footer>
</x-modal-card>

<x-modal-card> gives you the header, the close button, consistent borders and padding, and a <x-slot:footer> with its own top rule. Note w-[min(92vw,32rem)] rather than max-w-* — that is how a modal's responsive width is written here.

The button sits outside the <form> and points at it with form="my-form": the footer is the form's sibling, not its child, and a submit button outside its form does nothing without that reference.

3. Opening, from anywhere, with the arguments mount() takes:

wire:click="$dispatch('openModal', {
    component: 'partner.modules.new-version-modal',
    arguments: { moduleId: {{ $module->id }} }
})"

The name is the component's alias in flat form. Avoid :: in it — Livewire 4 runs the name through normalizeName() before looking it up, so an alias containing :: never resolves.

4. Closing: $this->closeModal() from the server, or wire:click="$dispatch('closeModal')" from the markup.

Why not roll your own: the backdrop, the focus trap, Escape to close, returning focus to the button that opened it, and locking background scroll are all in the package. A hand-built modal looks right and leaves a keyboard user tabbing around the page behind it.

Charts — <x-chart>

One component for every chart, and the library (ApexCharts) is bundled inside the platform — never load Chart.js or ApexCharts from a CDN: your app runs inside the platform's page, and a script from outside runs outside its bundle and its security policy and draws in colours that know nothing about the theme. The checker fails the build on it (remote-chart-library).

{{-- from a Livewire component: pass the data the way your query yields it --}}
<x-chart
    type="bar"
    :series="['Present' => $present, 'Absent' => $absent]"
    :categories="$days"
    :title="__('my-app::stats.attendance')"
    format="percent"
    height="280"
/>

<x-chart type="donut" :series="['Present' => 42, 'Absent' => 3, 'Late' => 5]" />

<x-chart type="line" :series="[['name' => 'Fees', 'data' => $fees]]" :categories="$months" format="currency" currency="SAR" />
  • type: line (default) · area · bar · column · scatter · radar · heatmap · treemap · pie · donut · radial.
  • series takes what your query yields, unreshaped: a flat list, a keyed list (keys become the categories or slices), a name→list map (one series per key), [{name, data}], and {x, y} points for scatter. Proportional charts also take [{name, value}]. null is a gap on the line, not a zero.
  • format: number · currency (+ currency="SAR") · percent, with precision for decimals. Numbers are formatted in the page's language.
  • Colours come from the platform tokens: your app's colour (from brand in the manifest) first, then the platform palette; axis text and grid from the same tokens — so the chart follows dark mode and RTL by itself and redraws when the theme flips. :colors overrides the palette when you must.
  • Live updates with no code: pass :series from a property or method on your Livewire component; every re-render updates the chart in place with an animated transition. No event, no entangle.
  • Empty is handled: all-null values (or an all-zero pie) show an empty state (emptyText changes the copy). Zero on a line is a value, not an absence.
  • :options is the escape hatch: any raw ApexCharts option, merged last — reach for it before writing new ApexCharts( yourself (which the checker flags: hand-driven-chart).
  • Two slots: <x-slot:actions> beside the title (a filter, a range toggle) and <x-slot:footer>.

Reference files in the platform

  • resources/views/livewire/partner/modules/module-edit.blade.php — hero header + sticky tab nav + save bar.
  • resources/views/livewire/partner/modules/notifications/notifications-index.blade.php — table + empty state + version picker.
  • resources/views/livewire/partner/modules/notifications/notification-form-modal.blade.php — full modal form.

AI support

Apps that use artificial intelligence must declare it explicitly in their manifest through two fields:

  • aiSupport (boolean): does the app use AI?
  • aiMode (enum): either own (the partner's own tooling) or platform (Okta's unified AI engine).

Where to declare it in the platform

  • When creating the app: an "AI support" card appears between "Basic information" and "Pricing" on /partner/modules/create.
  • When updating versions: the same card lives in the version editor's "Overview" tab, so you can add AI support to a specific release without modifying the parent app. A declaration at the version level overrides the module-level declaration.

The options

  • own — your app manages its own AI providers (OpenAI / Anthropic / Gemini / ...) and keys. No additional approval from the platform team is needed.
  • platform — your app consumes the central AiManager in okta-web. Declaring it IS approving it — there is nothing to wait for after that.

How platform actually gets approved

The runtime guard (EnsureAiPlatformApproved) asks two columns on the modules row in okta-web: ai_support and ai_mode = platform. It does not read your raw manifest, and there is no queue. Those columns are filled from your manifest on two paths:

Path When
Publish The publish review that approved your manifest is the platform signing off on its aiMode — no second approval request
Push to sandbox Immediately on push, with no production publish in the way

In sandbox: declare it, push, and it works — with no platform-admin approval of any kind.

A sandbox drops the approval question entirely: the app is installed in no real entity, the data is nobody's, and the point of the environment is to find out whether the feature is worth submitting at all — so an approval decision that blocks the trying is a decision about a question nobody has asked yet. Even a revoke does not reach sandbox: if the platform team revokes, production stops and your sandbox keeps working.

The reverse does not hold: pushing to sandbox does not clear a production revoke. On a shared deployment your sandbox tenant and production read the same module row, so if a push cleared the flag any partner could take back access that was removed for cause, just by pushing to their own sandbox.

(This was broken: the sandbox install path never extracted the two columns, so an app declaring aiMode: platform and pushed to sandbox got a permanent 403, and the only way out was a full production publish.)

The Okta team can still revoke on abuse. A revoke stops calls immediately, and access returns on the next publish that passes a human review.

Reading the 403 when it comes

The refusal now carries a reason naming which of the two cases it is — they need opposite fixes:

{
  "error": "ai_platform_not_approved",
  "reason": "not_declared",   // or "revoked"
  "message": "..."
}
  • not_declared — your declaration was never recorded: check aiSupport/aiMode in your manifest and make sure the app was published or pushed to sandbox. Do not wait for anybody.
  • revoked — the Okta team took access away: talk to them.

Headers: X-Ai-Approval-Required: 1 and X-Ai-Denied-Reason: <reason>.

Detailed reference

See ai-support.en.md for the full validation rules, manifest examples, and FAQ about cost and upgrading between own and platform.

AiManager usage examples

These examples apply to aiMode=platform only. Apps using aiMode=own call their providers directly (see the last sub-section).

Injecting AiManager

use App\AI\AiManager;
use App\AI\AiException;

class StudentReportsController extends Controller
{
    public function __construct(private AiManager $ai) {}

    public function summary(Request $request)
    {
        try {
            $summary = $this->ai->summarize($request->long_text);
            return response()->json(['summary' => $summary]);
        } catch (AiException $e) {
            return response()->json(['error' => $e->getMessage()], 502);
        }
    }
}

Laravel resolves AiManager automatically through AiServiceProvider (registered as a singleton). You can also use app(AiManager::class) or app('ai').

chat() — simple conversation

$reply = app(AiManager::class)->chat(
    prompt: 'Summarize the following student report in 3 bullet points',
    context: [
        ['role' => 'user',      'content' => 'Student report: ...'],
        ['role' => 'assistant', 'content' => 'Sure, let me read the report.'],
    ],
    opts: [
        'model'         => 'gpt-4o',     // optional — default picked by Okta's engine
        'temperature'   => 0.3,
        'max_tokens'    => 500,
        'system_prompt' => 'You are a professional educational assistant.',
    ],
);

stream() — streaming response (for interactive UIs)

use App\AI\AiManager;

return response()->stream(function () {
    $full = app(AiManager::class)->stream(
        prompt: 'Explain the concept of factor analysis',
        context: [],
        opts: ['temperature' => 0.5],
        onChunk: function (string $chunk) {
            echo "data: " . json_encode(['text' => $chunk]) . "\n\n";
            ob_flush();
            flush();
        },
    );
}, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);

In Livewire, use wire:stream or update a public property from inside the onChunk callback.

complete() — text completion (no chat history)

$completion = app(AiManager::class)->complete(
    text: "In his essay, the student wrote: 'Education is the path to",
    opts: ['max_tokens' => 50],
);
// result: "...progress and renaissance in modern societies, and therefore..."

summarize() — summarize a long text

$summary = app(AiManager::class)->summarize(
    longText: $student->report_full_text,
    opts: [
        'max_tokens' => 300,
        'system_prompt' => 'Summarize in one paragraph, focusing on strengths and weaknesses.',
    ],
);

translate() — translation

$ar = app(AiManager::class)->translate(
    text: 'The student excels in mathematics and sciences',
    toLocale: 'ar',
);
// result: "الطالب متفوّق في الرياضيات والعلوم"

Error handling

use App\AI\AiException;
use Illuminate\Http\Client\ConnectionException;

try {
    $result = app(AiManager::class)->chat($prompt);
} catch (AiException $e) {
    // Provider failure, quota exceeded, model unavailable, ...
    Log::warning('AI request failed', ['error' => $e->getMessage()]);
    return back()->with('error', 'Could not process your request right now. Try again later.');
} catch (ConnectionException $e) {
    // Service unreachable (rare)
    return back()->with('error', 'AI service is unavailable.');
}

Do not catch PlatformAiNotApprovedException (HTTP 403). Let it propagate — the EnsureAiPlatformApproved middleware will return a standard "platform approval pending" response to the user, along with the X-Ai-Approval-Required: true header. This is the platform's canonical approval-required UX, and swallowing the exception breaks it.

aiMode=own examples — partner's own tooling

If you picked aiMode=own, Okta plays no part in routing requests. You call your provider directly using its SDK. OpenAI example:

use OpenAI\Laravel\Facades\OpenAI;

$response = OpenAI::chat()->create([
    'model' => 'gpt-4o',
    'messages' => [
        ['role' => 'system', 'content' => 'You are an educational assistant'],
        ['role' => 'user',   'content' => $userInput],
    ],
]);

$reply = $response->choices[0]->message->content;

Or Anthropic Claude:

$response = Http::withHeaders([
    'x-api-key' => config('services.anthropic.key'),
    'anthropic-version' => '2023-06-01',
])->post('https://api.anthropic.com/v1/messages', [
    'model' => 'claude-opus-4-7',
    'max_tokens' => 1024,
    'messages' => [
        ['role' => 'user', 'content' => $userInput],
    ],
])->json();

$reply = $response['content'][0]['text'];

Your responsibilities in own mode:

  • Store API keys securely (env vars, KMS, ...)
  • Manage usage quotas and provider billing
  • Disclose to the tenant any data sent outside the platform
  • Comply with privacy and data-protection policies

See ai-support.en.md for the full reference on aiSupport/aiMode validation rules and migration paths.

AI Agent with tool use

Apps that want AI that performs actions (not just answers) use the Agent with the concept of "Tools". You define tools as PHP classes, and the AI decides when to call them — for example, when a user asks "add 5 committees with 30 students each", the AI autonomously calls the exams.committees.add tool with the right arguments.

This feature requires aiMode=platform declared in your manifest and recorded on the platform (by publishing, or by pushing to sandbox) — see "How platform actually gets approved" above.

Tool anatomy

Each tool in your app is a PHP class that implements the App\AI\Contracts\AiTool interface and lives under Modules/<ModuleStudly>/AiTools/. The platform auto-discovers them when the app loads — no manual registration required.

Full example: AddCommitteesTool in the Exams app

File: Modules/Exams/AiTools/AddCommitteesTool.php

<?php

namespace Modules\Exams\AiTools;

use App\AI\Contracts\AiTool;
use App\AI\Exceptions\AiToolException;
use Modules\Exams\Models\ExamCommittee;
use Modules\Exams\Services\CommitteeDistributor;

class AddCommitteesTool implements AiTool
{
    public function __construct(
        private readonly CommitteeDistributor $distributor,
    ) {}

    public function name(): string
    {
        return 'exams.committees.add';
    }

    public function description(): string
    {
        return 'Creates new exam committees and distributes students across them '
            .'according to the given settings. Use this tool when the user asks '
            .'to add committees or distribute students into committees.';
    }

    public function parametersSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'committee_count' => [
                    'type' => 'integer',
                    'minimum' => 1,
                    'maximum' => 100,
                    'description' => 'Number of committees to create',
                ],
                'students_per_committee' => [
                    'type' => 'integer',
                    'minimum' => 1,
                    'maximum' => 60,
                    'description' => 'Number of students per committee',
                ],
                'exam_period' => [
                    'type' => 'string',
                    'enum' => ['first', 'second', 'final'],
                    'description' => 'The exam period (first/second/final)',
                ],
                'distribution_strategy' => [
                    'type' => 'string',
                    'enum' => ['alphabetical', 'random', 'by_grade'],
                    'default' => 'alphabetical',
                    'description' => 'The distribution strategy',
                ],
            ],
            'required' => ['committee_count', 'students_per_committee', 'exam_period'],
        ];
    }

    public function requiredScopes(): array
    {
        return ['exams.committees.write', 'education.students.read'];
    }

    public function handle(array $params): mixed
    {
        $count = (int) $params['committee_count'];
        $perCommittee = (int) $params['students_per_committee'];
        $period = (string) $params['exam_period'];
        $strategy = $params['distribution_strategy'] ?? 'alphabetical';

        $available = $this->distributor->countAvailableStudents($period);
        $needed = $count * $perCommittee;

        if ($available < $needed) {
            throw new AiToolException(
                "Available students ({$available}) is fewer than required ({$needed}). "
                ."Reduce the number of committees or students per committee."
            );
        }

        $committees = $this->distributor->createAndDistribute(
            count: $count,
            studentsPerCommittee: $perCommittee,
            period: $period,
            strategy: $strategy,
        );

        return [
            'created' => $committees->count(),
            'total_students_assigned' => $committees->sum('student_count'),
            'period' => $period,
            'committee_ids' => $committees->pluck('id')->all(),
            'message' => "Created {$count} committees and assigned {$needed} students successfully",
        ];
    }
}

Embedding the chat UI in your app's page

<x-ai-agent
    :title="'Committees Assistant'"
    :placeholder="'e.g. Add 5 committees for the first period with 30 students each'"
    :system-prompt="'You are a smart assistant for managing exam committees in the okta-exams app. Help the user create, distribute, and manage exam committees.'"
    height="600px"
/>

How the flow works

  1. The user types: "Add 5 committees for the first period with 30 students each".
  2. The component POSTs to POST /api/apps/ai/agent/stream.
  3. The platform resolves the current app from AppContextManager, auto-discovers tools under Modules/Exams/AiTools/, filters them by requiredScopes() against the installation's granted scopes, and exposes the survivors to the model.
  4. The model responds with {"tool": "exams.committees.add", "args": {...}}.
  5. The platform executes AddCommitteesTool::handle() and returns the result.
  6. The component renders each step live (running → done) via SSE frames (tool_call, tool_result, text, done, error).

Error handling

  • AiToolException: a recoverable error — the message is fed back to the model so it can adjust and retry. Use it for validation failures.
  • Any other throwable: aborts the entire agent turn and logs the failure on the partner_install channel.

Permissions

Every tool declares requiredScopes(). The agent loop filters out tools whose scopes the installation doesn't hold before showing the toolset to the model. This means a write-capable tool stays safe even when the tenant only granted read scopes.

Iteration cap

Each agent turn is bounded to 5 iterations. After the cap, the model is asked to produce a final summary instead of more tool calls.

Authoring tips

  1. Tool name: use the pattern module.resource.action.
  2. Description: write it like API documentation — the model relies on it to decide when to call.
  3. JSON Schema: be strict (enums, min/max). It's the only contract between the LLM and your code.
  4. Return shape: keep it simple (array/scalar). The model summarizes it back to the user.
  5. Validate early: throw AiToolException for expected error paths so the model can retry sensibly.

Testing a tool locally

// tests/Feature/AddCommitteesToolTest.php
$tool = app(\Modules\Exams\AiTools\AddCommitteesTool::class);
$result = $tool->handle([
    'committee_count' => 3,
    'students_per_committee' => 25,
    'exam_period' => 'first',
]);
$this->assertEquals(3, $result['created']);

Okta mobile app

The Okta mobile app shows the user a set of service cards. Your app can expose a service that appears as a card inside it. This is declared per version — a later version can opt in without touching the original app.

Where to enable it

Partner dashboard → app → version → Integration tab → "Services inside the Okta mobile app" card → toggle it on, then provide the settings.

The three modes — pick one per version

Mode What you ship Where Rendered as
native Real Dart (Flutter) okta_app/native/<entry>/lib/ A native UI inside the Okta app
webview Web files okta_app/webview/ A confined WebView
external Nothing — you host it An HTTPS URL of yours A WebView on your URL

native is the only mode that gives you a native UI, and the only one that can reach device hardware through Okta.* calls. The other two go through the browser permission model, which the platform does not mediate.


Settings you provide

Setting Description
mode native (Dart code — a native UI), webview (web files in your repo) or external (a URL you host).
entry native: a .dart file under okta_app/native/<entry>/lib/. webview: repo-relative path inside okta_app/webview/. external: full HTTPS URL.
min_contract native only. The lowest host contract your code needs — per account type, under each row's entry in the Okta-app account-types list; a type without one inherits the block default, and the exported mobile.minContract mirrors the primary type.
allowed origins External only: HTTPS origins the page may be loaded from.
required scope Optional. The card is shown only if the user's active role holds this scope (from the scopes granted to the version). Empty = shown to any role that can open the app.
pass role claim Optional, external only. Adds the role claim inside the signed JWT. No database access either way.

File structure (webview mode)

Put everything rendered in the mobile app inside a okta_app/webview/ folder at the root of your app repo:

mobile/
├── README.md
├── manifest.json        ← optional metadata for the mobile surface
├── screens/             ← entry files rendered in the WebView
│   └── dashboard.blade.php
└── assets/              ← css / js / images for those screens

Example entry: okta_app/webview/screens/dashboard.blade.php.

Policy (enforced)

Mobile services are confined to the dedicated okta_app/webview/ namespace:

  • The webview entry must be a repo-relative path inside okta_app/webview/ only — no http(s):// scheme, no .. traversal. Anything else is rejected at save/review time.
  • Mobile screens may not link, navigate, or redirect to any platform/tenant page outside the okta_app/webview/ namespace.
  • This is also enforced at runtime by okta-web's app.webview middleware (the mobile surface is fenced to the /app namespace).
  • In external mode you ship nothing here — you host the page, and isolation comes from allowed origins + zero data binding.

Mobile dashboard

okta-app's first screen is a dashboard, not an app list. The launcher has a tab of its own — Apps, in the bottom bar — and the dashboard shows what the installed apps themselves have to say: today's numbers, the queue, what just happened.

Your app can put a block on it. Every mode may — native, webview and external alike — because a block is data, not a surface.

The portals get the same home

The student's and the guardian's portal homes are dashboards too, with the portal launcher on its own Apps tab — the same shape the tenant home has. Your block reaches a portal automatically wherever your app declares an audience for it; nothing new to declare, and audiences narrows there exactly as it does for roles — write ["guardian"] to brief guardians only.

Two things differ, both by design:

  • The audience parameter carries the portal. Your provider's first argument (and the role field of the external POST) is student or guardian where a tenant call passes the role key. Same parameter, same meaning — who is reading — and a provider that ignores it briefs every audience the same figures, which is a fine default.
  • The portal is cross-tenant, so your block appears once per school. A guardian with children at two schools that installed your app gets two blocks — the figures differ, and okta-app labels each with its school's name. You do nothing for this: your provider runs once per school in that school's context, exactly as it always has.

The rule everything else follows: data, not design

You return figures and labels; okta-app draws them, identically for every app. That is not a limitation to work around — it is the reason a dashboard carrying five apps still reads as one screen. A block that brought its own layout would be a partner's card sitting inside our dashboard, and the reader's first question — who is telling me this, and how old is this number? — would get five different answers.

It is also why a block costs nothing: no mini-app compile, no sandbox, no contract version. Five blocks are five array returns, not five interpreters.

Declaring it

Inside mobile in the manifest:

"mobile": {
  "supported": true,
  "mode": "native",
  "entry": "main",
  "dashboard": {
    "enabled": true,
    "title": "حضور اليوم",
    "title_en": "Today's attendance",
    "provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
    "audiences": ["tenant-admin", "teacher"],
    "cache_ttl": 300
  }
}
Key Required Description
enabled Must be true. Do not ship a disabled block — omit the whole thing.
title / title_en (one of) The block's heading. Without it the card is headed by your slug, which tells the person holding the phone nothing.
provider embedded A class under Modules\ implementing MobileDashboardProvider.
endpoint external An https URL that receives one signed POST.
audiences — Role keys. Narrows, never widens (see below).
cache_ttl — Seconds. Clamped to 30..3600.
portal_scope — per_tenant (default) or combined — how the block reads on a portal home when one person meets several schools.
mode — Who draws the inside: data (default — the host draws your figures) or native (your Dart draws it — see below).
entry with native The card's Dart entry file under okta_app/native/<package>/lib/.
min_contract with native The okta-app host contract the card needs. Floor 21; lower values are clamped up.

💡 Instead of editing manifest.json by hand: the MCP tool set_mobile_dashboard writes the block straight onto an editable version and runs the publish checks immediately; get_mobile_surface reads it back. The repo sync (sync_from_manifest) carries it too.

The transport follows integrationType and is not declared. An embedded app names a class the platform calls in-process; an external one names a URL the platform signs one request to. Declaring both — or the wrong one — is rejected at publish, because a second switch for something the manifest already answers means the first app to set them inconsistently publishes a block that never appears.

Embedded: implement the contract

namespace Modules\OktaHdor\App\Services;

use App\Services\PartnerApi\Contracts\MobileDashboardProvider;

final class DashboardProvider implements MobileDashboardProvider
{
    public function dashboard(string $roleKey, string $locale): array
    {
        $today = Attendance::query()->whereDate('taken_at', today());

        return [
            'stats' => [
                [
                    'label' => $locale === 'en' ? 'Present' : 'حاضر',
                    'value' => number_format($today->clone()->where('status', 'present')->count()),
                    'tone'  => 'success',
                ],
                [
                    'label' => $locale === 'en' ? 'Absent' : 'غائب',
                    'value' => number_format($today->clone()->where('status', 'absent')->count()),
                    'tone'  => 'danger',
                ],
            ],
            'rows' => [
                ['title' => 'Year 1', 'subtitle' => 'A', 'value' => '98%'],
            ],
            'open_label' => $locale === 'en' ? 'Open register' : 'فتح السجل',
        ];
    }
}

It runs inside your own module context, reading your schema with your credentials exactly as your own pages do. Scope every query to the current tenant.

External: receive a signed POST

POST <endpoint>
X-Okta-Timestamp: 1755859200
X-Okta-Signature:  HMAC-SHA256( "<timestamp>.<body>", install_signing_secret )

{"tenant_id": 42, "role": "tenant-admin", "locale": "en"}

Verify the signature before answering, and answer in the same array shape. The timeout is 6 seconds; past it you are treated as unreachable.

Nothing identifying the person is sent. A figure on the dashboard is about the school, and you do not need to know which member of staff is looking at their phone to count today's absences.

The payload

[
    'title'      => "Today's attendance", // optional — overrides the manifest title
    'stats'      => [ ['label' =>, 'value' =>, 'caption' => ?, 'tone' => ?] ],
    'rows'       => [ ['title' =>, 'subtitle' => ?, 'value' => ?, 'tone' => ?] ],
    'open_label' => 'Open register',      // optional — omit to show no button
    'message'    => null,                 // a note shown with the figures
]

value is a string you have already formatted. Percentages, currencies, ratios and counts share no formatter, you know which you are producing, and a numeric field would force the client to guess — or carry a format vocabulary that grows with every partner.

tone is a meaning, not a colour: neutral | success | warning | danger | info. The host resolves it to a palette that matches the rest of the screen in both light and dark; a hex you pick would match neither.

Limits are enforced: 6 stats, 8 rows, 120 characters per string. The excess is truncated silently — a block that takes the whole screen is not a block.

Keys outside the vocabulary are dropped, never passed through. An unrecognised key that reached the client would become a de-facto part of the contract the first time a partner relied on it.

Narrowing by role: it narrows, never widens

audiences are role keys that restrict the block to some of the people who can already see your app. The dashboard is built from the catalog the user already receives, so an app hidden from a role cannot brief that role through the back door. Leave it empty for every role that can see the app.

Failure is a state, not an exception

Throw when you fail. The platform catches it, logs it, and shows your block offline — or with its previous figures marked stale if any were cached.

Do not return an empty array instead. Empty is indistinguishable from "there is genuinely nothing today", and would show an operator a confident zero on a morning your query was broken.

The platform also keeps a day-long fallback copy to answer with when you cannot: yesterday's attendance marked "not current" is more use to someone standing at a school gate than an empty box.

Drawing the card yourself — mode: "native"

Everything above holds — and then an embedded app can go one step further and draw the inside of its card in real Dart, through the same source-on-device runtime its mini-app already uses:

"dashboard": {
  "enabled": true,
  "title": "حضور اليوم",
  "title_en": "Today's attendance",
  "provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
  "mode": "native",
  "entry": "okta_app/native/card/lib/main.dart",
  "min_contract": 21
}

The host keeps the frame. The corners, the header with your app's name and icon, the sync chip and the "open" button stay the platform's; your widget owns the inside of a box whose edges it cannot reach — including its height: the card is clipped at 320pt, not scrolled, because a scrollable inside a dashboard that itself scrolls steals the drag from the reader.

provider stays required, and that is the design, not a leftover. The figures it returns are the card an older phone draws — and the card this phone draws the moment anything goes wrong. Every failure lands there, silently, in the same session: source that will not compile, a widget that throws while building, a first frame that does not arrive within 500ms of the runtime handing over, a device whose host contract is below min_contract. There is no partner-visible error state on the home screen — the reader gets your data card, and the home screen never breaks because of a partner. Test your card by making it throw on purpose: what you should see is your own figures.

  • entry is a package of its own. It lives under okta_app/native/<package>/lib/ like every native entry, and should be a dedicated package (okta_app/native/card/) rather than a second file inside your app's package — delivery slices one package per entry, so a card entry inside the app's package would download and compile your whole app to draw one card.
  • min_contract is required and 21 is the floor — 21 is where the card runtime shipped, so lower values are clamped up. A device below it never mounts the card and shows the data card instead; that refusal is deliberate and invisible, not an error.
  • The sandbox is the mini-app's sandbox. Same Okta.* API, same isolation, same theme. One symbol exists for cards specifically: Okta.openMiniApp() opens your full app — the same act as the host's own "open" button. Deep navigation stays in the app; a card is a summary, not a screen.
  • Portals multiply the card, not its state. A guardian with children at two schools gets two cards, compiled and mounted separately, sharing no state, no tenant and no delegate — the same per-school isolation the full mini-app has.
  • Embedded only. A native card compiles from your signed source bundle, and an external app ships no code — mode: "native" is refused at publish; keep mode: "data" with your endpoint.

set_mobile_dashboard writes all three keys and runs these checks at dev time; the manifest schema and the publish gate repeat them.

Before you publish

  • Your block appears only to people who can already see your app in the catalog.
  • cache_ttl is clamped to 30..3600 at both ends — the published manifest shows the number that will actually be used, not the one you wrote.
  • provider must live under Modules\. The platform constructs this class by name, so anything outside your module namespace is a manifest choosing a platform internal to instantiate — and the instanceof check is too late, because the constructor has already run.
  • Nothing in a block is tappable except one button that opens your app. No links, no HTML, no images.

Display screens (Okta Screen)

A phone is opened by a person; a screen is opened by nobody. A television on the classroom wall, a panel in the school lobby, a display by the complex's gate — a machine that runs all day with no user in front of it, driven by a separate app called Okta Screen (Android TV / Google TV, and Windows). It signs in by pairing only: no account and no password, just a code the organisation's administrator mints from the device registry and types on the screen once.

What sets a screen apart from a phone is where it stands, not who holds it: at pairing the administrator picks its place — the whole organisation (a lobby, a corridor), particular grades, or particular classrooms. Your app declares which places it serves, and the platform offers it to the screens standing there.

And a place is a set, not one id. A television at the head of a corridor serves both "1st secondary/1" and "2nd secondary/3"; a board in a wing serves two grades. A screen is given a set of one kind — classrooms together or grades together, never a mix — and it reaches you separated, so you lay them out however your screen wants (two columns, two tabs, one merged roll).

Declaring it — mobile.screen

One row per place, and each row names its own entry file:

"mobile": {
  "screen": {
    "enabled": true,
    "title": "نداء الفصل",
    "title_en": "Class roll call",
    "min_contract": 21,
    "auto_launch": true,
    "places": [
      { "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" },
      { "scope": "tenant",  "entry": "okta_app/native/screen_lobby/lib/main.dart",
        "title": "لوحة الإعلانات", "title_en": "Notice board" }
    ]
  }
}
Key What it is
places[] Required. One or two rows — one per place you serve.
places[].scope Required. "section" (a classroom) or "tenant" (the whole organisation). Each place at most once; a repeat keeps the first row and the second is refused by name. There is no "grade" in the manifest — see the note below.
places[].entry Required, per row. A Dart file under okta_app/native/<package>/lib/, in a package of its own. Exposes Widget main().
places[].title / title_en Optional. Overrides the block's title for this place alone — "Class roll call" in the classroom, "Notice board" in the lobby.
title / title_en The default every row inherits when it names none of its own. Every place must end up with a title — its own or this one — or the declaration is refused.
min_contract Optional. Host-contract floor for the screen code; omit to inherit mobile.minContract. Block-level, because it describes the app and not the place.
auto_launch Optional, default true: a screen whose catalog holds only your app opens it by itself. Block-level too.

Why an entry per place. The package is compiled on the device: a screen downloads the source of the package declared for ITS place and builds it there. One shared entry would therefore make every classroom television download and compile the lobby code to draw a roll call it will never show. A classroom screen and a lobby screen are different screens, and the declaration says so: one place = one row = one package. The boilerplate ships both — okta_app/native/screen/ (the roll call) and okta_app/native/screen_lobby/ (the notice board).

The two places MAY share one file if you would rather ship a single package that branches on Okta.context()['screen']: write the same path in both rows. Then it is one artifact by your choice rather than by the format's — and the platform knows it is one build, not two.

A screen placed on grades is served by a section declaration — you never declare grade. An administrator can put a screen on particular grades (the wing screen), but the manifest does not know the word grade and will not learn it: no published manifest carries it, so requiring it would have shipped the place dead — every existing screen app refused on the first wing screen. A grade screen therefore takes the same section package, because what it shows is what a classroom screen shows over a slightly wider set.

And a tenant declaration does not serve it: a lobby board addresses the whole school, and putting it on a wing screen puts the wrong building on the wall. If you want your app on wing screens, declare section.

The legacy flat shape still reads. The first version of this block was a single entry with a flat scopes[]; if your manifest still has it, it is read as one row per listed scope, every row carrying that file — a manifest that published cleanly keeps publishing. It is never written back: what is stored and published is always places[].

The block is independent of mobile.supported. An app that serves the classroom screen and offers nothing on the phone declares screen alone and leaves supported: false — and the repo sync, the editor and the MCP tool all carry it without erasing it. It is embedded apps only: the screen compiles your code from your signed source package, and an external app ships none.

What the screen receives — Okta.context()

The sandbox is the mini-app sandbox: the same Okta.*, the same okta_kit, the same theme. A screen adds a few keys to Okta.context() and no new symbol — so no contract bump and no higher minContract:

final ctx = Okta.context();
final String scope = '${ctx['screen']}';   // 'section', 'grade' or 'tenant'

// Every place, separated, in the order the administrator arranged them:
final List ids = (ctx['section_ids'] as List?) ?? const [];
final List names = (ctx['section_names'] as List?) ?? const [];

// And on a wing screen, the grades instead:
final List gradeIds = (ctx['grade_ids'] as List?) ?? const [];
final List gradeNames = (ctx['grade_names'] as List?) ?? const [];

// The singular stays, holding the FIRST place — for an app written before
// the set existed:
final dynamic sectionId = ctx['section_id'];
final dynamic sectionName = ctx['section_name'];

Read the lists, not the singular. A screen may stand in two classrooms, and section_id names only the first — an app that reads it shows half of what is on the wall, and nothing tells it so. The lists are absent when the screen has no places of that kind (a lobby board, or a host older than the set), so read them as above: as List? then an empty default. And never split a joined string to get them — a classroom name may contain your separator.

And on a wing screen the singular is absent entirely, not null: that screen stands on grades, not classrooms, so neither section_id nor section_name is in the map at all (and the reverse on a classroom screen: no grade_id). Calling a method straight on the missing key — ctx['section_id'].toString() and the like — kills your app inside the interpreter with type 'Null' is not a subtype of type '$Instance' in type cast, and if it happens in initState it repeats every frame. This has happened on a school wall. Read the lists, or check the singular before you use it.

You will rarely need to name the places yourself: every student row from /api/apps/education/students already carries its own classroom and grade, so group them from that. The lists here are for drawing headings and ordering columns, not for filtering — the filtering happens on the server (below).

['screen'] is still delivered — but it is no longer the routing mechanism: the platform picks the entry declared for the place each screen stands in, so the classroom package never runs anywhere but a classroom. It stays useful for a file you deliberately pointed both places at (branch on it), and for a guard that says "this is a lobby board hung on a classroom screen" instead of drawing an empty one. Its value is 'grade' on a wing screen — which receives the section package, as said above, so read grade_names there if you draw a heading.

Read the keys with ['…'] as you read locale, and check for null explicitly before interpolating (see the '$value' lesson in the native section). tenant_id, locale and is_dark arrive as usual; role_id is empty because a screen has no role.

A screen sees its own places only — enforced by the server

This is not advice for your code. A screen paired to classrooms reaches student data through /api/apps/education/students pinned to its places on the server:

  • no filter at all → the pupils of its classrooms, not the school;
  • ?section_id= naming a classroom outside them → an empty page;
  • /students/{ulid} for a pupil outside them → 404, as if it did not exist;
  • /education/sections → its own classrooms.

A wing screen is the same one step wider: the pupils of its grades' classrooms, and a filter naming a grade or classroom outside them empties the page.

So a roll-call app on a classroom screen cannot list the school even if it asks. A filter naming a place outside the screen answers with an empty page, not an error — which is exactly what a classroom that does not exist answers, deliberately: telling the two apart would have made walking identifiers a way to learn what the school has.

A tenant screen sees what your phone app sees, under the same scopes the installation was granted — there are no screen-specific scopes.

And none of this needs a new scope or a contract bump: the places arrive in Okta.context(), a map the host serialises as-is, and the filtering happens on the server from the device's own row. Your app works as it is and declares nothing new.

Example: class roll call (okta_app/native/screen/lib/main.dart)

import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';

Widget main() => const RollCallScreen();

class RollCallScreen extends StatefulWidget {
  const RollCallScreen({super.key});
  @override
  State<RollCallScreen> createState() => _RollCallScreenState();
}

class _RollCallScreenState extends State<RollCallScreen> {
  List<dynamic> students = [];

  @override
  void initState() {
    super.initState();
    load();
  }

  Future<void> load() async {
    // No filter: the server pins the request to the screen's classroom.
    final res = await Okta.get('/api/apps/education/students?per_page=100');
    if (res.ok) {
      // body stays dynamic; OktaJson.rows unwraps the {data: [...]} envelope.
      final dynamic body = res.body;
      final List<dynamic> rows = OktaJson.rows(body);
      setState(() { students = rows; });
    }
  }

  @override
  Widget build(BuildContext context) {
    final dynamic ctx = Okta.context();
    final dynamic section = ctx['section_name'];
    final palette = OktaPalette.of(Okta.isDark());
    final List<Widget> rows = <Widget>[];
    for (final dynamic student in students) {
      rows.add(Padding(
        padding: const EdgeInsets.all(12),
        child: Text(OktaJson.strOr(student, 'full_name', '—'),
            style: TextStyle(fontSize: 36, color: palette.textStrong)),
      ));
    }
    return Scaffold(
      backgroundColor: palette.surface,
      appBar: OktaAppBar.build(section == null ? 'Roll call' : 'Roll call — $section', palette),
      body: ListView(children: rows),
    );
  }
}

Example: the lobby notice board (okta_app/native/screen_lobby/lib/main.dart)

Another package and another file — not a branch inside the first one. Note that it asks about no pupil at all: everyone walks past a lobby, so the starter reads from your own endpoint (/api/<slug>/notices) and needs no platform scope.

import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';

Widget main() => const LobbyBoardScreen();

class LobbyBoardScreen extends StatefulWidget {
  const LobbyBoardScreen({super.key});
  @override
  State<LobbyBoardScreen> createState() => _LobbyBoardScreenState();
}

class _LobbyBoardScreenState extends State<LobbyBoardScreen> {
  List<dynamic> notices = [];

  @override
  void initState() {
    super.initState();
    load();
  }

  Future<void> load() async {
    final res = await Okta.get('/api/my-app/notices');
    if (res.ok) {
      final dynamic body = res.body;
      final List<dynamic> rows = OktaJson.rows(body);
      setState(() { notices = rows; });
    }
  }

  @override
  Widget build(BuildContext context) {
    final palette = OktaPalette.of(Okta.isDark());
    final List<Widget> rows = <Widget>[];
    for (final dynamic notice in notices) {
      rows.add(Padding(
        padding: const EdgeInsets.all(12),
        child: Text(OktaJson.strOr(notice, 'title', '—'),
            style: TextStyle(fontSize: 36, color: palette.textStrong)),
      ));
    }
    return Scaffold(
      backgroundColor: palette.surface,
      appBar: OktaAppBar.build('Notice board', palette),
      body: ListView(children: rows),
    );
  }
}

The full versions — each with a refresh loop and its stop in dispose — ship in the boilerplate under okta_app/native/screen/ and okta_app/native/screen_lobby/.

Tips for the big screen: larger type than you would use on a phone (it is read from the back of the room), no touch — do not rely on gestures or text fields — and refresh on a schedule rather than pull-to-refresh. The screen keeps your app open for hours, and Timer is not bridged on this runtime (see item 14 in the native chapter), so the refresh loop calls itself again after await Future.delayed(…) — never with a callback passed to it — with a stop flag you set in dispose, and a long interval: every turn is a round trip to the host. Run flutter test tool/validate.dart inside every screen package you ship: the gate compiles one package, so a green classroom screen says nothing about the lobby.

D-pad support and the menu key

No touch, ever, on a screen: control is a TV remote (direction keys + OK) or a keyboard on Windows (arrows + Enter). What follows was measured in this sandbox, not inferred from Flutter in general — the difference between the two matters a great deal here.

Stock widgets support the remote automatically, with no extra line of code. ElevatedButton, TextButton and ListTile: arrows and Tab move focus onto them, and OK/Enter (and the gamepad A button) fires the onPressed/onTap inside your on-device-compiled code. That is all d-pad support takes — use those widgets for anything the remote is meant to reach.

And a card you drew yourself is reachable too — since contract 25 (package:okta_focus). This is the new part; before it, no trick made it possible:

import 'package:okta_focus/okta_focus.dart';

OktaFocus.first(               // ← holds focus on the first frame
  myBigCard(student),          // ← anything you drew: Container, Row, image…
  () => _call(student),        // ← fires on OK/Enter, and on tap
)
  • OktaFocus.item(child, onSelect) — the ordinary one, and the one to reach for.
  • OktaFocus.first(child, onSelect) — the same, and it holds focus on the first frame. One per screen: two widgets asking for the first focus is two answers to a question with one, and which wins is not something to build on.
  • OktaFocus.watched(child, onSelect, onFocusChange) and watchedFirst(...) — tell you when focus enters and leaves, so you can enlarge or tint your own tile.
  • OktaFocus.reachable(child) — the arrows reach it, nothing is pressed: for a row in a long list the ring should travel through rather than skipping a block of content.

The host draws the focus ring, in the shell's colour, with its width always laid out so gaining focus never nudges the row your tile sits in. You do not draw it and cannot hide it: a board on a wall with no visible ring is a board nobody in the room can navigate. Use watched to add your own emphasis on top of it, never instead of it.

autofocus: true on a stock button still does nothing, because the bridged button has no such parameter to arrive through. OktaFocus.first is the answer.

Focus, FocusNode, FocusTraversalGroup, KeyboardListener and PopScope do not exist here at all — each fails with a compile error (Could not find declaration), not a runtime one. Two things follow:

  • A bare GestureDetector is never reachable by the remote. A widget becomes a traversal target by owning a FocusNode, and there is no way to give it one without Focus. Touch and mouse on Windows still work on it, and a TV remote simply cannot reach it. Wrap it in OktaFocus.item (or put the action on a ListTile) — never leave an action on a bare GestureDetector on the screen surface.
  • You could not swallow the menu key even if you wanted to, and OktaFocus did not change that. Intercepting raw key events is structurally impossible from your side, not merely discouraged: the only key that reaches your code is select, and no KeyEvent object crosses into the sandbox at all. The arrows keep moving focus, and the menu key (Menu on the remote, or F1/Escape on a keyboard) and the back key always belong to the host and open its menu (the launcher, reload your app, settings) — the one way out of a lone app running in kiosk mode (auto_launch), where there is no "back" because there is nothing else to go back to.

And a passive screen with no buttons is a perfectly legitimate shape (a roll call, a notice board): the menu key keeps working because the host holds a fallback focus whenever nothing on your page holds one.

No MediaQuery and no LayoutBuilder — neither is available (the first is not bridged, the second fails to compile like the rest above). Your screen therefore cannot measure its own canvas at all, so design straight to the fixed footprint: a 1080p television at ~320dpi reports a logical size of about 960×540, not 1920×1080 — which is why the examples above use type larger than you would on a phone (36pt is not an arbitrary choice).

auto_launch describes your intent, not a guarantee. The screen's own administrator holds a device-level switch ("always show the home screen") that overrides auto_launch for every app on that particular screen, so it always opens onto the grid of installed apps even when your app is the only one declared there. Do not assume your app will simply appear with no interaction: build its first screen so it reads fine whether the user arrived there automatically or by tapping a tile on the grid — which is exactly how it gets opened from the simulator and during review anyway.

Designing the screen surface — sizing, layout, and the back button

A screen is seen from across a room by many passing eyes, not held close by one — that flips the usual phone-design priorities:

  • Design for the real baseline of roughly 960×540 logical px (a 1080p television at ~320dpi), not a phone canvas scaled up. Build for this footprint directly rather than designing a phone layout and stretching it by a fixed factor — proportions that read as balanced on a phone go either empty or crowded on a screen with a completely different, wide-and-short 16:9 aspect ratio.
  • Leave a safety margin from the edges (overscan): some real TV panels — older ones especially — physically crop a few pixels off every edge. Never place text or an interactive element flush against the screen's edge; leave at least 24-32dp of margin around the entire content area.
  • Type much larger than you would ever use on a phone — it is read from the back of a classroom or a lobby, not arm's length. 36pt, used in this chapter's examples, is a reasonable floor for body text, with headings well above it. Use strong contrast between text and background (not close shades of grey) — read colours through OktaPalette.of(Okta.isDark()) rather than hard-coding them, so your app follows the screen's own light/dark mode, which its administrator sets from that device's own settings, not from your app.
  • Few, large interactive elements — not a dense small grid: navigation is entirely direction-key input on a remote, so every extra tile in a grid is one more press for someone with no touch to jump straight to it. Prefer a short list of large cards over a crowded grid of small ones. The focus ring is drawn for you — by Flutter on stock widgets, by the host on OktaFocus tiles — so do not replace it with a faint hand-rolled highlight that is hard to make out from across a room.
  • Keep information density low, generally: a screen is usually a passive display (a roll call, a notice board, a live tally) glanced at in passing, not a dashboard someone studies closely. One clear message or one readable list beats four stat cards, a table and a chart crammed onto one page.

The remote's back button belongs to the host, not your app. The physical "back" key (Back on a TV remote, or Escape on a Windows keyboard) is intercepted entirely at the host level before it ever reaches your own widget tree, and its behaviour is fixed by how many apps are installed, not by your code:

  • A single installed app (kiosk): back is swallowed completely — nothing happens, deliberately, so a passive display can't be nudged off its state by a stray press. The only way out is the menu key (see the previous section).
  • More than one app: back sends the user straight to the app grid (the launcher) — your widget tree is never consulted and gets no notification of this at all.

So do not build internal navigation (a list drilling into a detail view, say) that depends on the physical back key to go up a level — that event never reaches your code in the sense phone apps usually assume. If your screen needs a way out of a detail view back to a list, put a visible "back" control on the screen itself — a stock button or a ListTile, not a bare GestureDetector (see the remote-reachability rule above), placed first in reading order, or carrying OktaFocus.first — and drive it from your own widget state (setState), not from Navigator.

Trying the screen on the virtual device (the simulator)

You do not need a television to try it: the portal's «Virtual device» (the app's tab, or /dashboard/simulator) mounts the very same screen surface in the browser — the same dart_eval engine your code is compiled on by a display, and the same source bundle from your branch.

  • The «Display (Okta Screen)» surface appears in the surface picker when the version declares mobile.screen, and is selected automatically for an app that has no phone surface at all.
  • The place comes first: one row per declared place (organisation screen / classroom screen), each naming its own entry file. The simulator compiles the chosen place's file alone — exactly as a display does — so an app serving both places is tried twice, one package per place.
  • The frame is a 960×540 logical television (dpr 2, no safe-area insets) — the size an Android TV really reports, not a phone turned sideways. The device panel offers other sizes for comparison, but the layout is judged at this one.
  • Okta.context() exactly as a display writes it, key for key: screen (tenant|section), for a classroom section_id and section_name, and an empty role_id because nobody signs in to a screen. The classroom is simulated (sim-section / «Simulated classroom») — from the «Persona» panel you can type a real sandbox section's ULID and name if you want to follow its data. Changing the place or the section is a relaunch, not a repaint, because on a display too the context is injected at startup.
  • Branch source only: «Installed on sandbox» is disabled for a screen — no display is paired to the sandbox to download its bundle from.
  • The remote is your keyboard: arrows and Tab move focus, Enter is OK. What an arrow cannot reach in the simulator, a remote cannot reach on the television either (see the D-pad section).
  • A limit you must know — data is NOT pinned to the classroom here. /api/apps calls in the simulator go through the sandbox seat (an admin account), not a screen device token, so education/students returns the whole school, not the classroom. On a real display the server pins the section onto the context (sectionScopeId) and nothing else gets through — a server guarantee that does not depend on your code, and one that can only be tested on a paired screen. In the simulator, filter by section_id yourself if you want to see what the classroom will see.
  • From MCP: simulator_start {slug, surface: "screen", screen_scope: "section"} (or "tenant"), then simulator_screenshot / simulator_ui / simulator_tap as for any session — the tool's reply names the place, the simulated classroom and the data limit above.

When your app fails on a screen — the error code and platform reporting

A screen nobody stands behind must neither go silently white nor show a red error tree to a whole classroom. So when your app fails — a compile error, an exception while building, a load timeout — the display shows a status screen carrying an error code of the form MA-XXXXXXX (MA = mini-app, then a fingerprint derived from the error's type and text — the same algorithm as okta-app's codes, so the same failure yields the same code on every device). The operator reads it off the television and reports it to you.

  • The code reaches the platform on its own: the display reports the failure to okta-web once per code per session (POST /api/device/client-errors with the paired device's token), with the message, the stack, your app's module_slug and the device's identity — so the failure is attributed to your app, not to the screen.
  • You read it on your app's «Errors» page in the portal, or through the MCP tools: recent_errors for the latest, app_errors for the triage list, and get_error with the code the operator read — from a code on a television to the full stack with nobody in between.
  • Repeats fold: failures with the same fingerprint land on one row with a counter (occurrences_count), so a hundred screens showing the same failure are one row, not a hundred reports.
  • What is not reported: an app that runs and shows something wrong is invisible to the platform — the report is for failures that prevent drawing. The way out on the device itself is the host menu (menu key → reload the app).

From the editor and MCP

  • Simulator: the «Display» surface on the virtual device (section above), and from MCP simulator_start with surface: "screen" and screen_scope.
  • Editor: the "In-Okta-app services" card → Display screens section — a checkbox per place that reveals its own entry file and its own title, and visible even with the phone surface switched off.
  • MCP: set_mobile_screen writes the block (a full replace, or null to remove it) and runs the publish checks early; get_mobile_surface reads it back and prints a line per place with its entry.
  • Repo sync (sync_from_manifest) carries it exactly as it carries dashboard.

native mode — a mini-app written in Dart

You write real Dart — no JSON, no DSL. The Okta app downloads the source, compiles it on the device (once per published version, then caches it) and renders the widget your main() returns, with the full platform identity.

No compiled artifact ever leaves your repository. On publish, okta-web reads every .dart file under okta_app/native/<entry>/lib/ into a single signed source bundle, so what is reviewed is exactly what runs.

Layout (shipped ready in the boilerplate)

okta_app/native/main/
├── pubspec.yaml           ← pins okta_miniapp — do not bump the ref
├── analysis_options.yaml
├── lib/
│   └── main.dart          ← entry point: Widget main()
└── tool/
    └── validate.dart      ← the same gate CI runs

The entry is okta_app/native/main/lib/main.dart — a .dart file under lib/ only, no ... Enforced both at save and at publish.

The smallest thing that works

import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';

/// Okta calls this to obtain your root widget.
Widget main() => const HomeScreen();

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // The scalars, not `Okta.context()`: that returns a decoded **map** with
    // snake_case keys, so `ctx.tenantId` does not exist and `ctx['tenantId']`
    // is a missing key — and reading a missing key is the nastiest trap in
    // this runtime (below).
    return Scaffold(
      appBar: AppBar(title: const Text('My app')),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('Tenant: ${Okta.tenantId()}   Role: ${Okta.roleId()}'),
          ElevatedButton(
            onPressed: () => Okta.toast('Hello'),
            child: const Text('Say hi'),
          ),
        ],
      ),
    );
  }
}

The host contract — package:okta_host

Okta.* is the only way out of the sandbox. The current contract is 27.

Call What it does Min contract
Okta.contract() The host contract number on this device 1
Okta.locale() · Okta.tenantId() · Okta.roleId() · Okta.isDark() Identity and appearance, as plain scalars 1
Okta.context() The same values as a decoded map, keyed locale/tenant_id/role_id/is_dark — prefer the scalars 1
Okta.get(path) · Okta.getQuery(path, query) · Okta.post(path, body) · Okta.api(method, path, body, query) HTTP through the host — paths are allow-listed to /api/<your-slug>/… and the scope-gated partner API. Tenant auth is attached for you. Returns an OktaApiResponse — see "what they return" below 1
Okta.scanBarcode() · Okta.scanNfc() Full-screen scanners returning one read (String?) 1
Okta.uploadFile(path) Pick and upload to an allow-listed path — a decoded map, or null if the user cancels 1
Okta.toast(message) A host-branded notice 1
Okta.storeGet/storePut/storeDelete/storeKeys Durable key-value storage, namespaced to your app. storePut answers false on refusal; it never throws 5
Okta.playSound(name) success · error (or failure) · warning · chime — a closed vocabulary, each with a haptic. chime is «a pupil went through and counted», and is not a contract change (see «playSound is unchanged and is not replaced») 5
Okta.location() One coarse fix — a map: ['latitude']/['longitude']/['accuracy']/['error'] 5
Okta.preciseLocation() Best the device can do, slower — same shape 6
Okta.appIcon(size) Your app's icon as a host-drawn widget (the sandbox cannot load an image) 7
Okta.close() Leave the mini-app — the sandbox has no Navigator 9
Okta.openDocument(path, fileName) Fetch an API path and hand it to something that opens it. false is an ordinary answer 11
Okta.toolKeyStart/toolKeyStop/toolKeyReads/toolKeyChannels Tool-key readers (NFC/Bluetooth/wired/LAN gate) — a drain, not a stream 12
Okta.hasCapability(name) · Okta.requestCapability(name) Retired — the gate behind them was removed; both always answer true on 19+. Kept so published apps still compile. 13
Okta.remoteImage(path, size) A host-fetched, host-drawn image from an API path. A path, never a URL 15
Okta.cameraScanStart/cameraScanStop/cameraScanReads · Okta.cameraPreview(size) An embedded scanner inside your own layout — a drain, like the tool keys 15
Okta.studentHashid() · Okta.portal() The student your tab was mounted on, and the portal (student/guardian) — '' on any other surface 17
OktaTabs.floatingBar family (in okta_kit) Okta's floating bottom bar as a ready widget — no host call behind it 18
Okta.toolKeyFingerprint(key) A card's fingerprint as the student roster publishes it — for matching a scan with no network 20
Okta.onMessage(handler) Register the instant-message handler — one, and a second call replaces it. No unsubscribe: the host closes the socket on teardown. See "Instant messages" 22
Okta.playAudio(url) · Okta.stopAudio() Play an audio file from an absolute http(s) URL your own server serves, and silence whatever is sounding. A second call replaces what is playing, and stopAudio silences the player and the voice. See "Audible announcements" 23
Okta.speak(text) · Okta.canSpeak() Speak a string in the device's voice, and probe first. Ask canSpeak before offering "announce by name": many devices ship with no Arabic voice, and false is a sound answer, not a fault 23
Okta.canRecord() Can this device record at all? Ask before you draw a record button — a classroom display has no microphone, and a phone's permission can be permanently denied. It does not request the permission, so it is safe to call while drawing 24
Okta.recordStart() · Okta.recordStop() · Okta.recordCancel() One take at a time. recordStop returns an opaque handle naming the take (String?); the bytes never cross the bridge. See "Voice notes and file uploads" 24
Okta.playRecording(handle) · Okta.uploadRecording(path, handle) Let the person hear the take, then send it. A retired handle is refused, never swapped for another 24
Okta.uploadFileOfKind(path, kind) Pick and upload, with kind from a closed vocabulary: image · audio · video · document · any. The old uploadFile is unchanged 24
OktaNfc.canListen/start/reads/stop (in package:okta_nfc) The content of an NFC card — its number, the technologies it presents and the text written on it. Drain, not push; Android only; needs a grant 26
Okta.toolKeyStartOn(channels) Opens only the readers you name — nfc · keyboard · uhf_bluetooth. toolKeyStart() is unchanged and still opens every one. See “Open only what you need” 26
OktaFace.availability/start/reads/stop · OktaFace.preview(size) (in package:okta_face) Who the school's recognition camera saw — the person's type and id, never their image or template. availability answers with a word that says who can fix a refusal. Drain, not push; a Windows machine running the recognition service; needs a grant. See “Who the recognition camera saw” 27

The mini-app is granted no dart_eval permissions: no network, no filesystem except through these calls.

The card-key form — one form, whichever call returned it

Okta.scanNfc(), Okta.toolKeyReads() and OktaNfc.reads() hand back the key in the same canonical form okta-web stores: lower-case, no separators (:, -, spaces), no control characters. A reader that emits AA:BB:CC and one that emits aabbcc both reach you as aabbcc.

Compare directly. Do not .toUpperCase() or .toLowerCase() first: it would not hurt — the normalisation is idempotent — but it hides any future drift instead of surfacing it, and it makes your code look like it knows something about the form that it does not.

If your app carries a workaround for this, delete it. Okta.scanNfc() used to return raw UPPER-case while the tool-key path returned the canonical form, so a card enrolled on the tool screen did not match itself when read through scanNfc. It never looked like a fault: the scan succeeded and the lookup missed, so the app reported an unregistered person. Fixed in the host; the two surfaces agree now.

The same applies to the WebView bridge: the nfc event now carries the canonical form where it used to carry UPPER-case.

Open only what you need — toolKeyStartOn (contract 26)

Okta.toolKeyStart() opens every reader the device has, which is right for a gate that takes any card. It is wrong for a screen that reads one kind: the other radios are powered for nothing, and NFC is a session the OS grants to a single owner — so an app reading barcodes with NFC left open holds it away from Okta.scanNfc() and OktaNfc for as long as its screen is up.

// Closed vocabulary: nfc · keyboard · uhf_bluetooth
final ok = await Okta.toolKeyStartOn(['nfc']);
if (!ok) {
  // A device without that reader — an ordinary answer; offer something else
}

The same answer toolKeyStart gives: true when at least one opened, false when none did, and it never throws. Nothing else changed — toolKeyReads, toolKeyChannels and toolKeyStop work the same whichever call opened the readers.

Read toolKeyChannels() afterwards; do not trust the list you passed. That is not belt-and-braces: a name the host does not recognise is dropped, and a request with no recognised name left is read as "every channel" — so ['NFC'] with a capital opens all three rather than one.

The direction is deliberate. An app that computes its list at runtime and arrives at nothing should get a working reader rather than a dead one — a reader that silently opens nothing is the failure this capability exists to prevent. But it does mean a typo widens instead of narrowing, and nothing on screen can tell you. toolKeyChannels() can:

await Okta.toolKeyStartOn(['nfc']);
final live = await Okta.toolKeyChannels();   // ['nfc'] if the narrowing worked

And toolKeyStart() is untouched — it gained no parameter and was not rerouted. If your app opens everything, there is nothing for you to do, and no reason to raise minContract to 26 for a symbol you never call.

The simulator can lag the phone, and that is structural rather than a fault. The "virtual device" is a build artifact taken from okta-app and published into the portal by hand, so it carries a contract number of its own that may be older than the table above. A symbol above the simulator's contract does not answer false there — it does not compile at all, with a message naming a file you never wrote (Cannot find static method, or Cannot find import).

Today both simulator shells sit at contract 27, so everything in the table above compiles there. The rule stands for the next contract: when the phone moves ahead before the shells are rebuilt, exercise the new symbol on a real device.

Reading a card's content — package:okta_nfc (contract 26)

Everything above answers whose a card is. This answers what is on it: the same number, the technologies the chip presented, and the text written on it (NDEF records). It never writes to a card and never alters one — and it never will: writing is irreversible on hardware a school hands to a child.

The number is the same number. row['key'] here is byte for byte what Okta.scanNfc() returns for the same card: one reading on the host, one normaliser, one canonical form. An app that moves to this surface keeps resolving every registered tool through the same GET /tool-keys/resolve, with no re-linking and no second registry. Do not normalise it again, and do not upper-case it before comparing.

Ask before you draw

import 'package:okta_nfc/okta_nfc.dart';

if (await OktaNfc.canListen()) {
  // draw the listen button
} else {
  // fall back to Okta.scanNfc() — same number, one card at a time
}

canListen() answers false on anything but Android, on a device with no NFC, and when the school has not granted your app the capability. All three are ordinary answers about ordinary devices. A button drawn without asking is a button that answers a press with nothing — the failure this probe exists to prevent.

The scope you need

nfc.android_listener.read in your manifest's scopes[]. It is a resource of its own rather than a wider action on the tool-key scopes, deliberately: a tool key is an identifier the platform issued and can revoke, while the bytes beside it on the chip were written by whoever programmed the card and the school holds no register of them. Two different questions, so the school is asked twice.

The grant is read on every launch, so withdrawing it from the install screen closes the capability on the next open — no publish required.

minContract: 26 is mandatory

An app that calls this library must declare mobile.minContract: 26. Omitting it does not make the app work on an older host: a mini-app compiles on the device, so a missing library is a compile failure. The difference is that the host recognises a missing import and shows "update the app" instead of leaving the user in front of a compiler message.

And there is no "try 26, else fall back to 25" in one published version: the compiler resolves the symbol before any condition runs, so merely having the call in your code is enough. The choice is made at publish time — declare 26 and use both surfaces (this one and Okta.scanNfc()) as each situation needs, or declare nothing and stay on the old one everywhere.

Drain in a loop; do not wait

Nothing can call into your mini-app, so reads are collected and drained — the same shape as Okta.toolKeyReads():

Future<void> _drain() async {
  final rows = await OktaNfc.reads();
  for (final row in rows) {
    final key  = OktaJson.str(row, 'key');   // the same number scanNfc gives
    final text = OktaJson.str(row, 'text');  // what is written on the chip
    _handle(key, text);
  }
  await Future.delayed(const Duration(milliseconds: 300));
  if (_listening) {
    _drain();
  }
}

Guard the loop with your own flag: State.mounted is not bridged, so an async gap cannot ask whether it is still on screen. A loop that forgets itself runs until the app dies.

Row shape: key · at (ISO-8601 UTC, when the host read it) · text (every text record joined by newlines, or '') · technologies · records (each with tnf, type, text).

text sits beside records on purpose: the common question is "what is written on this card?", and nested structures coming out of a JSON decode need care inside the interpreter.

Limits

  • Android only. iOS gives a system sheet per read rather than a background loop, which is Okta.scanNfc()'s shape, not this one. The web has no session at all.
  • One session per device. OktaNfc.start() takes the radio from the multi-channel tool-key reader, so do not expect Okta.toolKeyReads() to keep producing NFC rows while you are listening — call Okta.toolKeyStart() again after OktaNfc.stop() if you want it back.
  • stop() throws the buffer away. Buffered reads are cards waved minutes ago, and replaying them into a fresh screen acts on something nobody saw.
  • The simulator refuses honestly: canListen() always answers false there, so the fallback is exercisable in the browser, and the real reading on an Android device.

Who the recognition camera saw — package:okta_face (contract 27)

A school runs the face-recognition service on a Windows machine in the building — at the gate, the front desk, the hall door. This chapter lets your app ask it to run while your screen is up, and tells you who it recognised: the person's type and platform id, and when they were seen.

Nothing else reaches you — no image, no template, no match score, no name. The host downloads the school's templates, with the permission of the person signed in on that very machine, and hands them to the engine on the same computer. A template in an app's hands is a template it could send anywhere, and a child cannot be issued a new face — so no symbol in this library can carry one, in either direction.

Ask before you draw — and the answer is a word

import 'package:okta_face/okta_face.dart';

final status = await OktaFace.availability();
if (status == 'ready') {
  // draw the start button
} else {
  // say why — the word says who can fix it
}
Word Meaning Who fixes it
ready A start will work —
unsupported Not a Windows machine — the engine lives on one. The simulator and display screens always answer this Nobody — it is a different device
not_granted The school has not granted your app face_recognition.matches.read The school, from the install screen
service_offline The service is not answering yet: the host starts it itself when you ask, and a boot can take a minute — or no service is installed on this machine Nobody while it boots — ask again. If it lasts, a technician: the Okta app's technical settings say why
no_permission The signed-in person may not load the school's faces onto this device. At a complex's gate, also: they no longer hold the two keys there (tenants.children.operate_apps and a permission of your app) Whoever grants the school's permissions — or the complex's
no_faces Nobody is enrolled for the model the engine runs The enrolment desk
busy The camera is held by something else on this machine — usually the enrolment screen Wait and ask again
unreachable The school's roster could not be fetched just now The network — retry later
too_large A complex's gate only: the tree is more than one gate may carry — over the platform's ceiling, or longer than the one message the service on this machine accepts. It is never loaded in part Nobody at the gate: each school runs a gate of its own (which loads that school alone)
service_outdated A complex's gate only: the service on this machine did not prove the Okta app started it, or cannot read the compact roster — an older build, or one started by hand. It still serves a single school, as it always did A technician: update the service, or close the hand-started one and let the app start its own

Read any word you do not recognise as "not ready" — the list may grow. Why a word and not a bool? Because each refusal is fixed by a different person, and one false would send all four to the same wrong place.

Three words pass on their own, so ask again: service_offline (the service is booting — the host started it when you asked), busy (the camera is freed) and unreachable (the network comes back). So do not ask once when the screen opens and stop there: ask again every half minute while the screen is up and recognition has not started, as the example below does. The host deliberately does not wait out the boot for you: availability() answers within seconds because it is awaited in your own code, perhaps on the same chain that serves your card readers — and two minutes of waiting there are two minutes in which no card is read.

The scope you need

face_recognition.matches.read in scopes[] — read-only, and it will never be anything else. It is read on every launch, like the NFC scope, so withdrawing it from the install screen closes the capability on the next open with no publish.

It is not a substitute for education.students.read: this grant tells you who was seen, by id, and the name comes through the students API if the school granted you that. One grant does not quietly become two. And person_id is the same id /api/apps/education/students answers with, so matching is direct.

minContract: 27 is mandatory

Contract 26's argument, word for word: the library compiles on the device, and an older host recognises the missing import and shows "update the app". There is no "try 27, else fall back" within one published version — the compiler resolves the symbol before any condition runs.

Start, drain in a loop, stop on the way out

bool _listening = false;
bool _closed = false;

Future<void> _open() async {
  if (_closed) return;
  final status = await OktaFace.availability();
  if (_closed) return;
  if (status == 'ready') {
    if (await OktaFace.start()) {
      _listening = true;
      _drain();
      return;
    }
  }
  // The service may be booting, the camera may be freed, the network may come
  // back — so ask again.
  if (_passes(status)) {
    await Future.delayed(const Duration(seconds: 30));
    _open();
  }
}

bool _passes(String status) =>
    status == 'ready' ||
    status == 'service_offline' ||
    status == 'busy' ||
    status == 'unreachable';

Future<void> _drain() async {
  final rows = await OktaFace.reads();
  for (final row in rows) {
    _arrived(OktaJson.str(row, 'person_type'), OktaJson.str(row, 'person_id'));
  }
  await Future.delayed(const Duration(milliseconds: 500));
  if (_listening) {
    _drain();
  }
}

void _close() {
  _closed = true;
  _listening = false;
  OktaFace.stop();
}

And _closed twice: before the question, so a delayed one does not ask — and start the service — for a screen that has gone; and after it, so the camera is not started for a screen that closed while the answer was on its way. Call _open() from initState and _close() from dispose.

Row shape: person_type (student or employee) · person_id · at (ISO-8601 UTC, when the host saw them). All three keys are always present. Read the row inside a for loop as above — never pass it through ?: (trap 15).

One row per arrival, not per frame: the engine recognises every frame twice a second, and the host folds that — somebody standing at the camera is one row, and only becomes a second row after being out of sight for a few seconds. So do not write your own de-duplication for that reason. The buffer keeps the newest 256 if you do not drain.

The preview: OktaFace.preview(size) is a host-drawn widget — the live camera while your run is going, and an empty box of the same size before start, after stop and while someone else holds the camera, so nothing around it moves.

One camera, and it has an owner

The machine has one camera and the service runs one thing at a time. So start() will not take it from anything else — it answers false, and availability() says busy. And stop() ends your run only: if the enrolment desk took the camera in the meantime, it is left alone.

The host keeps your run alive for you: if the service is restarted, or the enrolment desk borrows the camera and gives it back, recognition resumes by itself without a call from you — the alternative is a gate that went quiet at 7:40 and says nothing about it. So do not write a "restart if it stopped" loop — once it has started. Before that, asking again is yours (above).

Limits

  • Windows only, and only where the service runs. Phones, display screens and the browser answer unsupported, which is the branch most devices take — write it.
  • A school's roster at a school; the operated tree at a complex. When the platform lets this open operate the complex's schools (the launch tells the Okta app so — «Your app works inside a complex's schools»), the roster is the complex's own pupils and those of every school its copy manages whose copy was granted face_recognition.matches.read. Pupils only: in this mode no staff member is recognised, not a school's and not the complex's. The row keeps its three keys and names no school — place the pupil yourself, from your card roster's entity_id or on your server with ActForStudentsInTree. And the first availability() there waits for the whole tree to download (the faces are forgotten when the screen closes, and fetched again on the next open) — so at a complex, do not hold your card readers back until the face answers.
  • The simulator answers unsupported, honestly — a browser tab has no engine. Exercise the whole "not ready" branch there, and real recognition on the school's machine.

The embedded scanner: cameraScanStart arms, cameraPreview opens

Okta.cameraScanStart() arms the camera; it does not open it. The lens opens when you mount Okta.cameraPreview(size) in your tree, and the preview is not an optional display — it is the source of the reads. An app that arms the scanner and never mounts the preview gets nothing from cameraScanReads(), ever.

The order:

cameraScanStart()  →  mount cameraPreview(size)  →  drain cameraScanReads()

And true means "armed", not "open": what is settled before it returns is the OS camera permission, which is what false reports. Everything after that is decided by mounting the preview.

This changed in the host. cameraScanStart used to try to open the lens itself, and it failed on every device: the package refuses to start before the preview widget is built, and the preview was only built after a successful start — a closed loop that made the embedded scanner answer false from contract 15 onwards. If you tried it once and gave up, try it again with the order above.

A gate that works with no network — the roster and the fingerprint (contract 20)

GET /tool-keys/resolve asks the platform "whose card is this?" — and needs a network. At a school gate at 7am, the moment you most need an answer is the moment the network is what failed.

So the roster is downloaded ahead of time:

GET /api/apps/tool-keys/students        scope: tool_keys.links.read
{
  "students": [
    {
      "student_id": "01J2X…",
      "full_name": "…",
      "grade_id": "01J2A…",
      "section_id": "01J2B…",
      "fingerprints": ["9f2c1d…", "4b70aa…"]
    }
  ]
}

The rows carry fingerprints, not keys, and that is not a restriction — it is what made the roster possible at all. An NFC tool key is the card's UID, so a roster of raw keys is the means to write a card that opens the gate as any pupil on it. A fingerprint matches; it does not manufacture.

So do not compare a scan to the roster directly. Put it through this first:

final reads = await Okta.toolKeyReads();
for (final read in reads) {
  final fp = await Okta.toolKeyFingerprint('${read['key']}');
  if (fp == '') {
    // Could not fingerprint — no keying material cached and no network to
    // fetch it. This is NOT "card not registered"; do not treat it as one.
    continue;
  }
  final student = rosterByFingerprint[fp];   // the roster you downloaded
  …
}

Three properties you can rely on:

  • Stable for one card at one school — the same value every time, and the same whether an NFC or a UHF reader produced the key.
  • Different at another school for the same physical card. Never store a fingerprint from one organisation and compare it at another.
  • The keying secret never reaches your app. The host holds it and fingerprints on your behalf. If it reached you, every four-byte UID could be generated and the roster turned back into the cards — which is the thing the fingerprint exists to prevent.

The empty string is not "unregistered". toolKeyFingerprint answers '' when it could not fingerprint — no keying material yet, or a portal session with no organisation. An empty fingerprint compared against the roster matches nobody, so at a gate it looks exactly like a pupil who was never enrolled. Check for it explicitly and tell the operator the device could not verify, rather than that the pupil was refused.

A student with no card appears in the roster with an empty list rather than being left out: "not enrolled yet" and "not at this school" are different answers, and a roster of only card-holders gives both the same silence.

Declare minContract: 20 if you call toolKeyFingerprint. It is a new symbol in an injected library and your app compiles on the device, so calling it against an older host dies with Cannot find static method, naming a file you never wrote. The roster itself is an ordinary HTTP call and needs no floor.

Note for existing apps: this roster sits under the same tool_keys.links.read scope your app already holds if it uses resolve. No new scope and no re-consent from organisations — but its meaning widened: it was "confirm a card handed to you", and now also covers "download the list of who holds one".

Calling above the device's contract does not fail — it poisons the session

Every row with a bold contract above is backed by an external function the host registers. On an older host it is not registered, and invoking it does not return an error — it corrupts the interpreter for the rest of the session: later calls fail with nothing connecting them to the cause, and you hunt a bug in code that is fine.

Two answers; pick one per call:

  • Declare minContract equal to the highest number you use, so Okta shows "update the app" instead of running you on a host that cannot serve you; or
  • Guard at the call site: if (Okta.contract() >= 11) { … }, with a fallback.

The scalars, get/post, toast and scan* need no guard — they have been there since contract 1.

The floating bottom bar — OktaTabs.floatingBar (contract 18)

A rounded strip inset from the screen edges, floating over the content. Just using the widget is enough:

This is no longer the platform's shape. Okta's bar today is solid and pinned to the edge — OktaTabs.bottomBar; see "The fixed shape every app wears". The floating one keeps working for the apps built on it, so it is a silhouette you may choose, not the default one.

final labels = <String>['Home', 'Register', 'Reports'];
final icons = <IconData>[OktaIcons.home(), OktaIcons.list(), OktaIcons.chart()];

Widget main() {
  final palette = OktaPalette.of(Okta.isDark());
  return OktaTabs.floatingOver(
    content,                                                   // your page
    OktaTabs.floatingBar(labels, icons, _index, _onSelect, palette),
  );
}

floatingOver lays the bar over the body (Scaffold here has no bottomNavigationBar slot, so the composition is a Stack — and this method IS that Stack so no app rebuilds it). Give your scrolling child a bottom padding of OktaTabs.floatingReserve() so its last row can rise clear of the bar.

Customisation is layered — in the same shape as the rest of the class (separate methods, never optional parameters — dart_eval 0.8.5 crashes on an omitted optional of an injected-source static):

Method What it controls
floatingBar(labels, icons, selectedIndex, onSelect, palette) identity defaults from the palette
floatingBarWithDots(… , dots, …) + an unread dot on any tab flagged true (a short list is safe)
floatingBarTinted(… , background, ring, activePill, activeInk, idleInk) every colour in your hands

Pass colours as Color OBJECTS built from literals at your call site (const Color(0xFF6D428F)): a Color crosses interpreted parameters intact, while a raw int passed along and then fed to a bridged constructor arrives double-boxed and dies with a cast error.

Solid, not glass — deliberately: BackdropFilter is not bridged and BoxShadow is declared nowhere on this runtime, so the bar's depth cue is a one-pixel ring in the border colour — geometry, not a Border (the bridge declares that parameter as BoxBorder and never declares Border as its subtype).

Contract 18. Calling this family on a 17 host dies inside okta_kit with Cannot find static method, naming a file you never wrote. Declare minContract: 18, or guard with Okta.contract() >= 18 and fall back to bottomBar — which has been there since contract 1, and is the platform's shape today.

flutter analyze will flag Okta and package:okta_host as undefined — that is expected. The library is injected by the runtime at compile time and is not a real pub package. The authoritative gate is flutter test tool/validate.dart.

What Okta.get / Okta.post return — and how to consume it

Every HTTP call returns an OktaApiResponse: three fields and one derived getter.

Field Type What it holds
status int The HTTP code — and 0 when the call never reached a server (network down, or a path the allow-list refused)
body dynamic The decoded JSON body — or the raw string when it is not JSON
error dynamic A transport/permission failure description; null when none occurred
ok bool (derived) error == null and status in 200–299

Checking ok is enough for the happy path: it already combines "no transport failure" with "a success status", so you never need to write status >= 200 && status < 300 yourself.

final res = await Okta.get('/api/my-app/students');

if (!res.ok) {
  // status == 0 means the call never reached a server — do not show "the
  // server said…" there, because the server said nothing.
  Okta.toast(res.status == 0 ? 'Could not connect' : 'Server said ${res.status}');
  return;
}

// body is dynamic — keep it that way. See below.
final dynamic payload = res.body;
final dynamic rows = payload['data'];

if (rows is List) {
  for (final dynamic row in rows) {
    final dynamic name = row['full_name'];
    // …
  }
}

The indexing above assumes your endpoint returns a JSON object — it is your endpoint, so that assumption is yours to make. If it answers with something that is not JSON, body arrives as a raw string and indexing it with a String key fails at runtime. You cannot guard with if (body is Map): that guard promotes the receiver and walks you into the same indexing trap.

body is dynamic on purpose, and that is not a detail. If you "tidy it up" by giving it an explicit Map type:

final Map data = res.body;      // ← don't
final rows = data['items'];     // Cannot use variable of type String as index
                                //   to map of type <int, Color>

you walk straight into the Map-indexing trap described in the dart_eval list above, at compile time. The field arrives dynamic from the host for exactly this reason: leaving it alone is the correct thing to do, not the lazy one.

error versus status: error is for everything below HTTP — the network dropped, or the allow-list refused the path so nothing was ever sent. A server that answers 4xx/5xx arrives with error == null and the code in status. Both make ok false, but they are different failures to a user: one is "no connection", the other is "the server refused".

Okta.uploadFile does NOT — it returns a decoded map, not a class. Read it with ['status'], ['body'], ['file_name'] and ['error'] (note the underscore in file_name), and it is null when the user cancels the picker, so check for null before you touch the keys. There is no .ok here and no .fileName.

Why is this different from Okta.get? Because OktaApiResponse is named by Okta.api itself, and every app calls api, so the class always resolves. An injected library may not name a class your app never reaches: dart_eval resolves such a class only when the source pulls it in, so for an app that never uploads a file the name does not exist — and the failure lands on the injected library, naming a file the partner never wrote: CompileError: Unknown type OktaUploadResult. That is why uploadFile, context, location and preciseLocation all return maps.

final dynamic up = await Okta.uploadFile('/api/my-app/attachments');
if (up == null) return;                       // the user cancelled
final dynamic status = up['status'];
if (status is int && status >= 200 && status < 300) {
  Okta.toast('Uploaded ${up['file_name']}');
}

A complex reads its schools from the mini-app — ?entities=

Your app is open in a complex, and the pupils are in its schools. You need no server endpoint: the data routes themselves answer for the schools beneath when you name them, and Okta.getQuery carries the parameter on every host (no minContract bump). The example below draws a complex's dashboard: its schools with their logos, and each school's pupil count in one call. It was run as written on the mini-app engine; the lines that look fussy are there for the traps in «Traps the devices proved», further down.

// 1) the complex's schools, and whether this person may read them
final tree = await Okta.getQuery('/api/apps/entity/children', {'descendants': '1'});
if (!tree.ok) return;

final dynamic payload = tree.body;
// `== true`, never a bare read: an older platform does not send this key,
// and `== true` is the comparison that reads an absent key safely.
final bool allowed = payload['can_read_children'] == true;
if (!allowed) {
  setState(() { _allowed = false; });   // do not draw the download button at all
  return;
}

final List<String> ids = [];
String joined = '';
final dynamic nodes = payload['data'];
for (final dynamic node in nodes) {
  if (node['type'] == 'school' && ids.length < 20) {   // up to 20 per call
    final String id = '${node['id']}';   // interpolated, so the interpreter owns it (trap 1b)
    ids.add(id);
    joined = joined == '' ? id : '$joined,$id';
  }
}

// 2) each school's pupil count — one call, one key per school in the answer
final res = await Okta.getQuery('/api/apps/education/students', {
  'entities': joined,
  'per_page': '1',   // we want the total, not the rows
});
if (!res.ok) return;

final dynamic answers = res.body['entities'];
final List<dynamic> rows = [];
for (final String id in ids) {
  final dynamic answer = answers[id];
  // A school that answered carries no `error` key, and a refused one no
  // `total`. A missing key is a raw null: ask `identical`, never `== null` (trap 7).
  final dynamic error = answer['error'];
  if (identical(error, null)) {
    rows.add({'id': id, 'total': '${answer['total']}', 'error': ''});
  } else {
    rows.add({'id': id, 'total': '', 'error': '$error'});   // one refused school does not drop the rest
  }
}
setState(() { _schools = rows; });

In build, one loop over _schools; the logo is a path the host fetches (404 when there is none, drawn as empty space):

Okta.remoteImage('/api/apps/entity/children/${school['id']}/logo', 32)

One school, with its pupils and their photos — ?entity= returns that school's answer as is, so you read it with the same code you read your own entity's pupils with:

final one = await Okta.getQuery('/api/apps/education/students', {
  'entity': schoolId,
  'per_page': '50',
});
// then, per row:
Okta.remoteImage('/api/apps/education/students/${row['id']}/photo?entity=$schoolId', 40)

Three things shape this:

  • No loop inside a loop. dart_eval does not compile one (see «The supported dart_eval subset» below), so do not walk the schools and then each school's pupils in one function. Take from the envelope what needs no second loop (total, or error), and read one school's rows with ?entity= when it is opened.
  • A refused school is one key, not the answer — and its key has other fields. An answer carries the route's fields and no error; a refusal carries error (missing_scope with scope, or entity_not_found) and no total. Test which one you hold with identical(answer['error'], null): answer['error'] == null reads the missing key as false, and storing what it returns kills the mini-app.
  • can_read_children before the button, read with == true. A person without the permission has the whole request refused (children_not_readable), so do not draw for them what will be refused. A platform older than this feature does not send the field at all, and == true reads that as «no» — the same answer, for the right reason.

The whole rule — which grant a school is read with, and when a request is refused — is in the chapter «Companies and complexes: the tree and its schools' data».

Validate locally — the same gate CI runs

cd okta_app/native/main
flutter pub get
flutter test tool/validate.dart

This compiles your code against the exact device runtime, so anything outside the supported subset fails in CI rather than on a user's phone.

And pub get itself may refuse before anything reaches CI. pubspec.yaml carries a ceiling on the Dart SDK version (not Flutter's — see the environment: comment in the file itself; pub does not enforce the Flutter bound on the root package, measured rather than assumed) because okta_miniapp hand-writes class $Container implements Container, and Flutter 3.41 added a member (Container.isAntiAlias) the pub.dev release does not know about. A newer Flutter is refused by pub get with one line naming the version to install — not a false "Got dependencies!" followed by a compile failure later.

The supported dart_eval subset — read this before you write

The device runtime is dart_eval + flutter_eval, which support a subset of Dart/Flutter. The list below is not a style preference — every item breaks:

  • Host calls through the static Okta.* facade only — never top-level host functions.

  • No State.mounted — it is not bridged. Call setState directly.

  • Callbacks must be closure literals, not method tear-offs: onPressed: () => _doThing(), never onPressed: _doThing ("Cannot box Function").

  • Never pass cond ? null : closure to a callback slot. Pass an unconditional () => … and guard inside the method (if (_busy) return;).

  • Index JSON on a dynamic receiver — never through a Map-typed one. body['key'] compiles and dispatches correctly while body is dynamic. Give that receiver a raw-Map static type — an is Map guard promotes it, or you annotate a variable, parameter or field Map — and operator[] switches to static resolution. dart_eval 0.8.5 then binds raw Map to an arbitrary bridged instantiation (Map<int, Color>, MaterialColor's swatch shape) and rejects your String key at compile time: Cannot use variable of type String as index to map of type <int, Color>. The <int, Color> never comes from your data — don't go hunting for it. Keep JSON dynamic end to end (final dynamic v = body['key'];) and guard on the result (v is List), never on the receiver you are about to index. is List promotion is fine — only Map is affected.

  • No nested loops. dart_eval 0.8.5 throws a RangeError at compile time when a loop's body — directly, or through a method it calls — contains another loop. Flatten hierarchical data into one flat list first (on the server, ideally), then run ONE loop over it with a loop-free row builder.

  • Buttons: ElevatedButton / TextButton only — FilledButton and OutlinedButton are not bridged.

  • Layout: Row / Column (+ Expanded). Wrap and AlignmentDirectional are not bridged — build grids N-per-row with Row+Expanded, and reach for Directionality (not Align + AlignmentDirectional) for RTL.

  • Always pass flex: to Expanded / Flexible. The bridge does not apply the parameter's default, so an omitted flex arrives as null and is cast to int — type 'Null' is not a subtype of type 'int', thrown while the widget builds. Write Expanded(flex: 1, child: …). The same holds for any bridged widget whose optional parameter is a non-nullable int.

  • BoxDecoration carries only color and boxShadow. borderRadius, gradient, image and shape sit in the bridge commented out, and an argument the bridge does not declare is silently discarded rather than rejected — it compiles clean and the value simply never reaches Flutter. So BoxDecoration(borderRadius: …) paints square corners with no error anywhere, and validate.dart cannot catch it because nothing fails. Round with ClipRRect(borderRadius: …, clipBehavior: Clip.antiAlias, child: ColoredBox(…)).

  • BoxDecoration(border: …) does not compile at all. The parameter is declared, typed BoxBorder — but the bridge never declares Border as a subtype of it, and the compiler knows only what the bridge declares. An ordinary Border.all(…) fails with Cannot assign argument of type Border to parameter of type BoxBorder. Draw a hairline as a 1px box: Container(height: 1.0, color: …).

    Those last two are the same trap from opposite ends: an argument the bridge does not declare is dropped without a word, while one it does declare is type-checked without mercy.

  • No arbitrary Icons.* constants — use text or labels instead.

  • Prefer simple Map/List shapes over deep generics across the host bridge.

When your change reaches the device — and when the old copy stays

The Okta app compiles the source on the device once, then caches it. The key is not the contents of the bundle — that is the whole point. It has four parts:

Part Moves when
slug a different app
entry a different entry package under the same slug (see "Several entry packages" below)
payloadVersion the module row's updated_at moves — and every install carrying a new commit moves it
runtimeSignature a library the host injects changes, i.e. with a new Okta app release

On sandbox: push, then reinstall — do not bump the version

You do not need a new version for every change. The loop is:

  1. push your code to the version's branch;
  2. reinstall the same version on sandbox.

The install re-resolves the branch's commit every time (the commit_resolve step), so okta-web receives a different commit, the module row goes dirty, updated_at moves, payloadVersion changes, and the device's cache misses and recompiles. install_on_sandbox accepts any existing version, not only editable ones.

When the old copy really does stay

When none of the four moved. In practice there is one case: you reinstalled without pushing — same commit, nothing dirty, so the source is not re-pulled and nothing recompiles. That is correct rather than broken: there is nothing new to deliver.

This misleads more than it blocks. No error, no warning — you open the app, see exactly the old behaviour, and conclude your fix did not work. So the first thing to check is: did your push actually land on the branch the version points at? Not the code.

The bundle is built compressed at install — and rebuilt when it goes stale

Since compression was enabled the bundle is no longer built per launch. okta-web builds it once at install for each native entrypoint, gzips it and stores it, so a launch serves a file instead of walking your tree again. The Okta app downloads it, decompresses it and runs it.

The file is no longer frozen. It used to carry min_contract and capabilities as they read at install time and not move until you reinstalled — and that gave partners the worst debugging day there is: you publish a change, the version number moves on every device, and the old code keeps running behind it looking perfectly current. okta-web now compares the file's age, on every launch, against the tree it can actually deliver: once your new code has landed on the platform, one rebuild fires and the fresh file is served. And while a publish's code download is still in flight (or has failed), devices keep being offered the previous version — its number and its file together — never a new number wrapping old code.

What the portal shows is what devices receive on their next open — no manual reinstall for a manifest that moved.

Your normal loop is unchanged: push, then reinstall when the files change (pulling your source is still what an install does); a manifest-only edit now arrives on its own.

And on production

Tenants receive what is published. There a new version is the mechanism — not because caching works differently, but because nobody reinstalls on their behalf.

Publishing is two steps under the hood: the platform records your version, then downloads your code with a one-hour token. Devices are only ever told about the tree that actually landed — and a stalled download no longer stays stalled: okta-web retries it automatically with a freshly minted token, from the first device launch that meets the wedge and from a scheduled sweep every ten minutes. So "the portal says 1.4.0 but phones still offer 1.3.0" for a short while after publishing is the system being honest, not your update being lost; if it persists, open the module's status — it carries the exact pull error.

Conversely, an Okta app update moves runtimeSignature and recompiles your app on every device without you publishing anything.

This misleads more than it blocks. There is no error and no warning — you open the app, see exactly the old behaviour, and conclude your fix did not work, so you start undoing it and trying other solutions to the wrong problem. In fact your fix never reached the device at all.

If you see old behaviour after a change you trust, check the version number before you check the code.

min_contract — the host-contract floor, per account type

The host contract is versioned. Declare the floor your code depends on and the Okta app shows "update the app" instead of running a mini-app the device is too old for — and since the launch answer started carrying the floor, it says so before downloading the bundle, not after compiling it.

  • The floor belongs to the account type, not to the app. Each row of the Okta-app account-types list in the version editor has its own "minimum host contract" field under its entry (manifest: audiences[].minContract). A staff package written against a newer host must not lock the guardian package out of the phones it still compiles on. (And when you want a newer package for newer phones without locking the older ones out at all, that is versions[] — the next section.)
  • A type that declares none inherits the block default (mobile.minContract, which the MCP tool set_mobile_surface.min_contract writes). What the editor exports as mobile.minContract is a mirror of your primary type's floor, so a manifest that only ever set the block value keeps meaning exactly what it meant — nothing published before this field changed behaviour.
  • Declare the floor the host features you call actually need. (The old "≥ 13 because you declared a device capability" rule is gone with the gate.)
  • Applies to native only; the editor clears it in any other mode.
  • One .dart path is one contract. The same entry declared twice with different floors — an audience and the dashboard card, or a screen place — builds with the first declaration and logs a warning at publish; it is not refused. Give each floor its own package instead. (It is refused when one side is an extra entry line — see the next section.)
  • And the floor is optional — blank is a real answer: leave it empty and the entry runs on every device, the oldest included unless the device clears a bound line with a higher contract — the server tries the lines first and falls back to the default. okta-web resolves a missing floor as own → block → 1 and 1 is below every host that has ever shipped. That is what every app published before per-type floors declares, so nothing makes you pick a number until you need one. Written or resolved, though, it is a floor no extra line of that type may repeat: a default with no floor claims contract 1, and a line bound at 1 is a second answer to one question — see the next section.
  • Under the field the editor lists which okta-app releases run which contract (1.1.1 → 26 · …), mirrored from the table the Okta team keeps on okta-web (partners:sync-okta-app-releases, hourly). Pick the floor your code needs, then read there which phones it reaches.
  • MCP: set_account_types takes min_contract per row (native rows with an entry only; a value below 1 is refused by name); get_account_types and get_mobile_surface print each type's floor, the block default and the exported mirror; validate_manifest refuses a malformed minContract and warns on one path declared with two contracts.

Extra entries for an account type — audiences[].versions[]

A floor says do not run this entry under host X. It never says what to run instead — so a package written against a newer host used to leave you two options: lock the account type away from older phones, or stay on the old package forever. An extra entry line is the third: more than one entry for the same account type, and the server picks.

Every line is bound on exactly ONE axis, your choice:

Axis Says When
minContract "a build running host 28 or newer gets this" the precise question — the contract is what decides whether the package compiles on the device at all
minAppVersion "a build at or above 1.2.0 gets this" for the builds that cannot answer the precise question: every okta-app shipped before the X-App-Contract header reports a version and no contract

A line with neither is refused: it names an entry and never says who gets it, so no phone exists that the server could hand it to.

"audiences": [
  { "key": "teacher", "mode": "native",
    "entry": "okta_app/native/main/lib/main.dart",
    "minContract": 20,
    "versions": [
      { "minContract": 28,
        "entry": "okta_app/native/main_v3/lib/main.dart" },
      { "minAppVersion": "1.2.0",
        "entry": "okta_app/native/main_v2/lib/main.dart" }
    ] }
]

How okta-web picks, in order. The phone's contract comes from its X-App-Contract header, or is inferred from the release table when it only reports a version. Then:

  1. Contract-bound lines (highest floor first): the first floor the phone's contract clears. An unknown contract clears nothing here — the line names a host, and "probably" is not a host.
  2. Version-bound lines (newest bound first): the first bound the phone's version meets (and, when its contract is known, whose floor it clears).
  3. The default entry, whatever its floor — the device gate shows "update the app" exactly as it does today.

So a phone that reports neither a usable contract nor a version it can meet always gets the default. This is the whole compatibility promise: add a line and nothing that already works changes — every okta-app build older than the two headers keeps getting exactly what it gets today.

One host contract names one entry inside an account type — and the default entry's own minContract is one of them. A line sitting on the default's floor would win for every phone that reports a contract and lose for every phone that does not, which is an answer nobody means to give. The editor, the MCP tools and publish all refuse it by name.

The default entry's floor is optional. Leave it blank and the entry runs on every device unless the device clears a bound line with a higher contract — the server tries the lines first and falls back to the default. That is how it has always worked, since okta-web resolves an absent floor as own → block → 1. Write one when your package genuinely needs a newer host, not because the field demands it. Note that a default without a floor claims contract 1 on the server, so a line bound at 1 is refused as a second answer to the same question.

A version-bound line's floor is optional. Leave it out and it inherits the type's floor, which is what okta-web gives it anyway. Write it when the package needs a newer host than the default does; the editor fills it from the release table when it knows the contract that release shipped with.

You may leave entry empty when you declare lines, and it means "this type is served on those builds only". Say it on purpose: the type then disappears from every phone that meets none of the bounds, and from every phone that reports neither its contract nor its version. The editor warns beside the list, the MCP reply says it in words, and the reviewer sees it as a red badge.

One bound names one entry on the version axis too. 1.02.0 and 1.2.0 are the same release, and okta-web pins the canonical spelling into the signed launch URL — so declaring both is refused rather than left as a row no launch could ever reach.

One .dart path is still one contract, and here it is an error, not the warning the legacy overlap gets: an extra line naming a path another declaration builds at a different floor would be a second number that never reaches a device. Give each floor its own package.

Ten lines at most. The list is an entry history, not a table — drop the bounds nobody runs any more.

The simulator does not simulate this. A browser tab has no app version and no contract to compare, so it lists your extra entries and mounts the default. Try an extra package on a device running that host.

MCP: set_account_types takes versions per row (whole-list replace with the rest of the type, same as the default entry); get_account_types and get_mobile_surface print every line by the axis it is bound on and flag a type with no default; validate_manifest refuses a line with no bound, a malformed bound, a duplicate bound or contract, and an extra path that collides with another declaration's contract.

The usual path is to write the lines in manifest.json and call sync_from_manifest — they cross into the version exactly as the default entry does. While the platform is still rolling the shape out across both okta-web deployments, the lines are refused by name on write and the tools say so: that refusal is about the platform, not your manifest.

pubspec.yaml — do not bump the ref

okta_miniapp:
  git:
    url: https://github.com/TahdirIT/okta-miniapp.git
    ref: __MINIAPP_REF__      ← filled in by the platform when your repo is created

The ref is pinned to the exact runtime the published Okta app was built with, so what compiles for you compiles identically on the device. Bumping it yourself breaks that guarantee.

Manifest shape

"mobile": {
  "supported": true,
  "mode": "native",
  "runtime": "dart",
  "entry": "okta_app/native/main/lib/main.dart",
  "minContract": 12,
  "audiences": [
    { "key": "teacher", "mode": "native",
      "entry": "okta_app/native/main/lib/main.dart", "minContract": 12 }
  ]
}

runtime: "dart" is added automatically in native mode — do not write it yourself. minContract on the block is the primary type's mirror; the value that counts is the one on each audience, and a row without one inherits the block's.

Several entry packages under one slug

<entry> in okta_app/native/<entry>/ is the name of a standalone Dart package, and nothing ties you to one. Two audiences may point at two different packages:

"audiences": [
  { "key": "staff",  "mode": "native",
    "entry": "okta_app/native/staff_app/lib/main.dart", "minContract": 26 },
  { "key": "family", "mode": "native", "portal": "guardian",
    "entry": "okta_app/native/family_app/lib/main.dart", "minContract": 20 }
]

Each package is bundled and cached separately. The source bundle carries the lib/ tree of the requested entry only — sibling packages are never folded in — and the cache key carries entry beside the slug for exactly this reason. (Without it the two collide: same slug, same payloadVersion, so whichever launched first wins the entry and the other replays its bytecode and dies with Cannot find package:….)

So if your staff screen and your guardian screen are substantially different, split them into two packages — cleaner than one large if, and each compiles and caches on its own.

Each package also carries its own floor: above, the staff package needs contract 26 while the family package still runs on 20, so a guardian whose phone is on 20 keeps opening the family screen while the staff screen shows "update the app" — one app, two floors, and no phone locked out by a package it never runs.

When the app will not open at all — the payload failure family

The card appears, you tap it, and loading fails before any of your code runs. This is not a bug in your mini-app: the launch endpoint succeeded (so the install, the scope and the audience are all fine) and the source-bundle download then failed.

The entry shape is enforced three times, not two: on save in the version editor, on publish in okta-web, and once more when the bundle is served — and that third one is what you see as a failed load. Every possible cause is on the server side:

Cause What it means
The module is not deployed as code on this instance The database row exists; the code is not on disk
okta_app/native/<entry>/lib is missing from the deployed module The module shipped without its native tree
The entry file is not under that lib/ entry points at a file that does not exist
entry does not match okta_app/native/<entry>/lib/**.dart An old version grandfathered in before the shape gate
The bundle > 10 MB, or > 128 files You are over the assembly limits

There is no per-file limit. It was 256 KB and was removed: the compile and the transfer only ever see the total, so splitting a file served nothing. What remains is 10 MB for the whole project and 128 files, and the refusal names the file being read when the budget ran out.

The 10 MB ceiling is almost always hit by data, not logic. A Dart project that reaches it nearly always carries generated files: base64 images, huge const maps, generated translations. Move the data out of Dart and behind an endpoint you read with Okta.get — what arrives over the network is not compiled on the device, while every byte in your bundle is. This is not about transfer: the bundle travels compressed, but the on-device compile works on the full source, and it runs on the isolate that draws the UI.

What to do: report your app's slug and the time you tried to the platform team. The server logs the exact refusal, and the app shows it as text on screen in non-production environments — so the answer exists on one side or the other and does not need guessing. The first thing to check yourself: that the entry path in your manifest matches a path that really exists in your repo, character for character.


Device capabilities — REMOVED, being rebuilt

A second permission axis stood here: mobile.capabilities[] in the manifest, a closed vocabulary of eleven device resources, a publish-time check, and a gate inside the Okta app that refused any call an app had not declared and the person had not consented to. The whole thing has been removed — vocabulary, gate, consent ledger, permissions screen, per-channel reader filter — and is being redesigned from scratch rather than patched.

What that means for you now:

  • Do not declare mobile.capabilities in a new manifest. The key is still ACCEPTED so that no already-published app fails its next publish, but nothing reads it: it is not normalised, not carried to the device, and not shown on the install screen.
  • Every device call simply works. Okta.scanNfc(), Okta.scanBarcode(), Okta.toolKeyStart(), Okta.location(), Okta.preciseLocation(), Okta.uploadFile(), Okta.openDocument() and storage need no declaration and raise no platform prompt.
  • The operating system still asks. Android and iOS show their own runtime dialog the first time your app touches the camera, location or NFC, and the person manages it in device settings. That was never part of this gate.
  • Okta.hasCapability() / Okta.requestCapability() still compile and now always answer true. They are kept because they are compile surface — a published mini-app that calls one would fail to compile ON THE DEVICE if the symbol vanished — so existing guards keep working and always take the granted branch. Write no new guards against them; the replacement re-uses these two names.
  • There is no "minContract ≥ 13" rule any more. Declare minContract for host features you actually depend on, nothing else.
  • A refusal is still not an exception. A device call can still answer null / false / a negative accuracy — absent hardware, an OS prompt the person declined, a reader that never paired. Keep checking return values; that discipline never depended on the gate.

The host contract moved to 19 for this. A device still on 18 or below enforces the OLD gate, and an app written for the ungated world is refused there silently — scanNfc answering null with nothing in any log — rather than with a compile error you could see.

Your tab in the student profile (mobile.student_profile_tabs) — native mode only

A guardian opens the Okta app, sees their children, taps one — and the screen becomes the student profile. This declaration gives you your own tab inside that screen: you own its whole body, your information and your actions and your layout, not a card in a template you fill in.

"mobile": {
  "supported": true,
  "mode": "native",
  "minContract": 17,
  "student_profile_tabs": [
    {
      "key": "attendance",
      "title": { "ar": "الحضور", "en": "Attendance" },
      "entry": "okta_app/native/student_tab/lib/main.dart",
      "portals": ["guardian"],
      "order": 10
    }
  ]
}

This is not the studentProfile block. That one adds panels, stats and actions to the student-profile page in okta-web, read by staff, and is discovered from Livewire classes in your repo. This is a native mini-app mounted on a guardian's phone. Two audiences, two hosts, two runtimes — so they are not one array. See Extending the student profile for that one.

And it is not an account type. mobile.audiences[] answers who opens your app; this answers where a surface mounts. Merging them would make "guardian" mean two different screens depending on a sibling field.

Fields

Field Required Note
key ^[a-z][a-z0-9_-]{0,63}$. An identity, not a label: the platform remembers the guardian's last-opened tab by it, so changing it forgets their choice.
title {ar, en}; Arabic is required. A bare string is read as the Arabic title. Up to 60 chars.
entry okta_app/native/<package>/lib/<file>.dart. May be the same file as your portal card, or its own package.
portals ["guardian"] by default. "student" is the same screen a student opens on themselves.
order Sort among your own tabs. Your position against other apps' tabs is the platform's to decide, not yours.
icon Up to 64 chars.

Four conditions, each of which drops the whole tab

  1. native only. The tab is handed the student's identifier. Under webview or external that identifier would have to be posted to a partner-hosted page — moving a child's id onto your server every time the screen opens, outside the Okta.* calls okta-web authorizes one by one. A declaration under any other mode is dropped.
  2. minContract ≥ 17. The mount point did not exist before that contract. An older device does not error and does not complain — it renders the profile with your tab absent, and nothing in any log says why.
  3. The education.students.read scope. The tab receives only the student's hashid; without this scope it cannot turn that into a student it knows, so it mounts and shows nothing.
  4. Three tabs maximum. The strip is shared: every app the tenant installed puts its tab in the same row, in front of a parent who came to see their child. An app taking eight seats is not extending the profile, it is annexing it — and the guardian pays in horizontal scrolling.

What you receive at mount — and how to read it

Your tab is mounted for one student, and you receive the student's hashid, never a numeric id. Not cosmetic: a sequential integer would let an app walk the school roster by incrementing; a hashid would not.

Two calls, new in contract 17:

// The student the tab was opened on. A function, not a property.
final studentHashid = Okta.studentHashid();

// Who is reading: 'guardian' or 'student'. One screen, two readers.
final portal = Okta.portal();

Both answer '', never null, when the surface is not a student-profile tab — the ordinary case on every other surface of your app. '' is not an error; it means "this is not a student-scoped mount". Do not fall back to "the current user" there: in a guardian's context there is no student in the session to fall back to, and an app that guesses one shows a parent another family's child.

Why '' and not null? Because '$value' on a null produces the four characters null — a perfectly well-formed string that walks straight into an API path with nothing to stop it. An empty string is checkable; 'null' is not.

Authorization is not yours to do and not yours to bypass. okta-web re-runs it on every call (a linked guardian, the student themselves, or a delegate with an effective delegation), so a tab reaching for another student gets a 403 rather than data. Do not build your own check, and do not stash the hashid to read later in some other context.

A complete, working example

okta_app/native/student_tab/lib/main.dart — an attendance tab listing the student's absences:

import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';

Widget main() => const AbsencesTab();

class AbsencesTab extends StatefulWidget {
  const AbsencesTab({Key? key}) : super(key: key);

  @override
  State<AbsencesTab> createState() => _AbsencesTabState();
}

class _AbsencesTabState extends State<AbsencesTab> {
  bool loading = true;
  String error = '';
  List<dynamic> rows = <dynamic>[];

  @override
  void initState() {
    super.initState();
    load();
  }

  Future<void> load() async {
    final sid = Okta.studentHashid();

    // "Not a student-scoped mount" is a legitimate state, not a failure —
    // say something a reader understands instead of calling the API with an
    // empty identifier.
    if (sid.isEmpty) {
      setState(() {
        loading = false;
        error = 'Open this tab from one of your children’s profiles.';
      });
      return;
    }

    // Your own namespace. okta-web has already checked this reader may see
    // this student before the call reaches your code.
    final res = await Okta.get('/api/my-app/absences?student=' + sid);

    setState(() {
      loading = false;
      if (res.ok) {
        rows = res.body['data'];
      } else {
        error = 'Could not load the record.';
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    if (loading) {
      return const Center(child: CircularProgressIndicator());
    }
    if (error != '') {
      return Center(child: Text(error));
    }
    if (rows.isEmpty) {
      // "No absences" is good news to a parent — say it, don't render an
      // empty table.
      return const Center(child: Text('No absences this term. 🎉'));
    }

    // "Your child" to a guardian, "you" to the student: one screen, two
    // readers.
    final who = Okta.portal() == 'guardian' ? 'Your child' : 'You';

    return ListView(
      children: [
        Padding(
          padding: const EdgeInsets.all(12.0),
          child: Text('$who missed ${rows.length} day(s) this term'),
        ),
        for (final row in rows)
          ListTile(
            title: Text('${row['date']}'),
            subtitle: Text('${row['reason']}'),
          ),
      ],
    );
  }
}

Three things worth noting in this example:

  1. main() returns a Widget, like any other mini-app — a tab is not a new kind of program, it is the same surface with a different mount point. Everything you know about native applies: the supported dart_eval subset, the limits, and the traps below.
  2. The empty state is not an error. Someone who reached the tab from outside a student profile deserves a sentence explaining that, not a spinner that never stops and not an API call with a blank identifier.
  3. Never show a parent an empty table. "No absences this term" is the information; it is what they came to find out.

Your scopes, and the platform's data

education.students.read is required because the tab receives an identifier and nothing else. If you need more than turning that into a student — the grade, the section, the timetable — request the matching scopes as described in Scopes. Your OWN data comes from your own namespace (/api/<slug>/…) with no extra scope, and it is usually most of what the tab shows.

Traps

  • A dropped tab leaves no trace. Any of the four conditions above drops the row at build time: the manifest publishes, the app installs, and the tab is simply not there on the parent's phone. That is why the version editor and validate_manifest refuse the row with an error, not a warning — a refusal where you read it beats an absence you never see.
  • Empty is worse than absent. A tab with an appealing title that shows nothing until data is set up is better left undeclared. Declare it when it has something to say.
  • The title has exactly one audience: the guardian. Do not put your app's name or an internal term in it — your app's name is already shown next to the tab.

Traps the devices proved — read these before you ship

Every item here took down a real app on a real phone, and none of them is caught by flutter analyze or tool/validate.dart. Compilation succeeds; the interpreter then dies in the user's hand.

1. Okta.* is a BRIDGE CALL, not a field read — cache it once. Every call round-trips to the host and comes back as JSON to be decoded again. Every time. One attendance app wrote:

bool get _ar => Okta.locale().startsWith('ar');   // a getter — 188 calls per build
String _t(String ar, String en) => _ar ? ar : en;

Read the identity once in initState and keep it in fields:

late final bool _ar;
@override
void initState() {
  super.initState();
  _ar = OktaText.isRtl(Okta.locale());
}

It cannot change without the host rebuilding the mini-app anyway. The rule is wider than identity: treat every Okta.* as network, not as a property.

1b. A value that ARRIVED from outside is not an interpreter value — and the message names the argument, not the receiver. Calling a bridged method (startsWith, toLowerCase, trim, contains, substring, even toString) on a value that came from outside the interpreter dies with:

type 'String' is not a subtype of type '$Value?' in type cast
#1  $String._startsWith

args[0] is perfectly fine — the receiver is the wrong shape, so do not chase the argument the trace names. Okta.* was fixed from the inside and no longer hands out values in that shape, but a JSON reader you write does:

String _text(dynamic map, String key) {
  return map[key] as String;        // the cast passes; the shape stays wrong
}

String _text(dynamic map, String key) {
  final dynamic value = map[key];
  return '$value';                  // interpolation makes the interpreter allocate it
}

Fix it at the mint, not at the call sites, and do the same in the int / double branches (value.toString() carries the identical defect and merely fires less often). For bools use == true rather than as bool.

2. Never nest ternaries — in an argument or an assignment alike.

final way = a ? (x ? '…' : '…') : (b ? '…' : '…');   // arrives null

The compiler allocates the outer conditional's result slot and the inner one writes elsewhere:

*L11: null, L12: null
8484: BoxString (L12)  <<< EXCEPTION

Branch and return instead. A single ternary is fine. And the type in the message (String, int) is just whatever the expression produced — do not read it as narrowing the rule.

3. An uncalled private method fails your own gate. unused_element is a warning, and flutter analyze is part of partner-module-policy. Delete a button, delete its handler with it.

4. A device call can still refuse — check what it returns. The platform's capability gate is gone, so nothing is withheld for lack of a declaration. What remains is the real world: hardware that is absent, an OS permission dialog the person declined, a reader that never paired. Every call answers in its own documented refusal shape — scanNfc → null, storePut / openDocument / toolKeyStart → false, location → a negative accuracy — and none of them throws. Ignore the return value and your screen shows a button that silently does nothing.

5. A stale ref makes your gate validate something the device does not have. The okta_miniapp pin in okta_app/native/<entry>/pubspec.yaml does not decide what the device compiles against — the host injects okta_host/okta_kit/okta_motion from its own binary. It decides what flutter test tool/validate.dart checks. So a pin behind the host means a gate validating a source that does not exist, and the failure surfaces on a user's phone:

CompileError: Cannot find static method OktaTabs.bottomBar

A symbol the host knows and your pin does not, or the reverse. The platform manages this value; if you hit an on-device compile error while your gate is green, the ref-versus-host match is the first thing to check.

6. Silent failure is worse than failure. A local capture is confirmed before the network is consulted — which is correct, it is what makes a scan survive a dropped connection — but it means an upload that never landed looks identical on screen to one that did. Say so: a sound, a message, a log row. Someone standing at a gate is watching the student, not a badge in the header.

7. is is not a safe test on a bridged value — and a missing key is its worst case. The most expensive trap in this runtime. dart_eval's Map bridge returns map[key] directly, and the null that a missing key yields is a raw Dart null, not the interpreter's $null. So value is String does not return false — it throws:

type 'Null' is not a subtype of type '$Value' in type cast

There is no defensive way to look at one: is throws, and == null is worse — it answers false, so the value sails on to the next call and dies there instead.

The rule is wider than a missing key: on a bridged value, a MATCHING is answers and a non-matching one THROWS. The key is present, the value is sound, and it still throws — caught on-device by the okta-hr team on the accuracy that Okta.location() returns:

L23: $"accuracy"   L24: 16.950000762939453

5436: Unbox (L24)
5437: NumLtEq (L31 <= L24)      ← `is double` matched, and the compare ran
5441: IsType (L24 is 12)        <<< EXCEPTION

type 'double' is not a subtype of type '$Value' in type cast

is double succeeded; four instructions later is int threw on the same value. So any chain of is tests against one value is a time bomb: the first test that does not match throws instead of answering false. The pattern is extremely common in JSON reading (is int, then is double, then is String) and works perfectly until a value arrives whose type is not the first one in your chain.

The one test that answers absence is identical(value, null) — true for a missing key and for an explicit JSON null, false for anything present, measured on the engine. Ask it first, and only then read the value:

final dynamic value = map['accuracy'];
if (identical(value, null)) return;                // absent, or null — the one safe test
final accuracy = double.tryParse('$value') ?? 0.0; // present: interpolate, then parse

Once the key is there, the safe read is interpolation — '$value' — the same fix as trap 1b above, there for boxing and here for inspection: the resulting string is interpreter-allocated, so every call on it is safe, and it answers the type without a single is. It does not survive absence: interpolating a missing key calls toString on the raw null and throws the same type 'Null' is not a subtype of type '$Instance'. An earlier version of this guide showed '${map['accuracy']}' answering "null" for a missing key — it does not; if you copied that line for a key that can be absent, put the identical test in front of it.

Use is only once the interpolated string has told you it will match.

And not a containsKey guard either. It was tried on-device and reverted: its boxing does not match operator[]'s, which is special-cased, so every guarded read fell through to its fallback — it answers "absent" for keys that are present. Do not write it.

The defence is upstream: make your endpoint always emit every field your mini-app reads, with null when it has no value. A key that is PRESENT holding a JSON null is completely safe — jsonDecode wraps recursively, so it arrives as $null and behaves — and only ABSENCE is fatal. It is your endpoint, so the guarantee is yours to make.

OktaJson protects you from the shape, not from absence: at excludes a body that is not an object (null, list, string, number, bool) so an unexpected reply cannot crash you, and strOr/number/list tolerate the wrong type. A missing key passes through them exactly as it passes through a direct read. The exception is OktaJson.flag, which uses == true rather than is and is therefore absence-safe.

8. setState(() => x = y) boxes twice — use a block body. An arrow closure returns its body's value, and an assignment is an expression, so the value is boxed once to write the field and again to return it — and the second box lands on something already boxed:

type '$bool' is not a subtype of type 'bool' in type cast
7287: SetObjectProperty (L3._exporting = L1)
7288: BoxBool (L1)  <<< EXCEPTION
setState(() { _busy = true; });     // produces no value, so no second box

Convert every one, not only the ones that crash: identical-looking sites in the same file shipped for weeks, and whether the double box lands turns on register allocation rather than on anything visible in the source.

9. Reading a bool out of a list is the same trap by another road. Indexing hands back an already-boxed value, and the compiler — seeing a static type of bool — emits BoxBool on top of it. Compare, don't read:

final on = i < flags.length && flags[i] == true;   // not `flags[i]`

The comparison produces a fresh bool instead of re-boxing the stored one. Strings and widgets index out of a list fine; bools (and numbers feeding a bridged parameter) are what bite.

10. Never initialise a loop counter from a parameter. An int crossing an interpreted parameter arrives boxed. Used directly it is harmless — the compiler unboxes it — but int i = from is a local copy: CopyValue moves the box, the compiler types the local from its int declaration and believes it unboxed, and the next comparison dies:

type '$int' is not a subtype of type 'num' in type cast

Use literal bounds and repeat the loop, one method per range. Do not widen this into "no arithmetic on an int parameter" — that rule is false, and index * 40 ships fine.

11. A primitive crossing an interpreted parameter gets double-boxed. A literal handed straight to a bridged constructor is fine. The same value passed through an interpreted function parameter first arrives boxed twice and dies with type '$int' is not a subtype of type 'int'. That is how an IconData _icon(int c) helper broke a five-tab bar, once per tab. Build bridged values from literals at the construction site, or return them from a zero-argument method the way OktaIcons does. Widgets and Strings pass through parameters fine.

12. Declared, but not wired: it compiles, then throws. A class can be known to the compiler and never registered with the runtime, so it passes validate.dart and then throws while building:

UnimplementedError: Tried to invoke a nonexistent external function

In flutter_eval 0.8.2 that is true of InkWell, InkResponse and SafeArea. Use GestureDetector for the first two and your own padding for the third.

The same trap exists on methods: list.sort() with no comparator compiles and then throws, because the runtime implementation reads args[0] without a ?. Always pass the comparator, or don't sort.

13. Long text wraps; it does not truncate. Text bridges neither maxLines nor overflow, and TextOverflow is not a bridged type at all — so there is no ellipsis, and a label wider than its box wraps, splitting a word across lines. Keep it on one line with FittedBox(fit: BoxFit.scaleDown, child: Text(…)) inside a bounded width, and omit alignment (the wrapper defaults it to Alignment.center).

14. No Timer — but there IS a clock, and Future.delayed is awaited, never handed a callback. DateTime (.now(), .parse, add, difference, …), Duration and Future.delayed are all bridged and wired, and so are jsonEncode/jsonDecode. Only Timer is absent — which rules out a PERIODIC callback, not a delay. So a drain loop (toolKeyReads, cameraScanReads) calls itself again after await Future.delayed(duration), and it needs your own flag to stop it: State.mounted is not bridged, so an async gap cannot ask whether it is still on screen.

A callback you pass to Future.delayed compiles and is never called: the bridge takes the duration alone and drops the callback without a word. A loop that writes Future.delayed(duration, () { _drain(); }) drains once and then goes quiet, with no error anywhere. Write it like this, inside an async method:

await Future.delayed(const Duration(seconds: 1));
_drain();

Measured on the runtime that runs on the device, and check_miniapp_source refuses the first form by name (future_delayed_callback). This guide's own drain loops used it until they were corrected — if you copied one from an older copy of the guide, replace it.

15. A JSON element does not survive ?:. The value of a ?: expression whose branch is an element of a decoded list or map comes out unboxed, and the next OktaJson call (or any is) dies on it:

type '_Map<$Value, $Value>' is not a subtype of type '$Value' in type cast
final first = rows.isEmpty ? null : rows[0];   // dies in OktaJson.str(first, …)
final first = rows.isEmpty ? {} : rows[0];     // the other branch changes nothing

dynamic first;                                  // assign inside an if
if (rows.isNotEmpty) { first = rows[0]; }

final name = rows.isEmpty ? '' : OktaJson.str(rows[0], 'name');   // read inside the branch

Measured on the engine: rows[0] directly, rows.first, rows[0] ?? … and a for loop are all safe — ?: alone unboxes. It was found by a test on the same engine the device runs, not by a school's complaint: it had been failing the contract-26 test in the host's own repository since the day it was written. Avoid it before it becomes one.

Instant messages (realtime)

A mini-app could ask, but never be told. Every capability before this number is the sandbox calling the host and waiting for an answer; there was no path for the host to call inward. So an app that wanted to know when its own server had said something had only one option: poll. A classroom screen asking "has anyone been called?" every two seconds, all day, about an event that happens twice a lesson. This channel reverses the direction — your app pushes news to its own surfaces.

Two halves, and only one bumped the contract — the difference is not a detail

Sending needed no contract bump. Publishing is an ordinary API call on an already-allowed path: Okta.post('/api/apps/realtime/publish', …) works on contract 21 with no new symbol, because Okta.post has sent since contract 1 and the partner surface is gated by scopes, not by symbols.

Receiving could not be expressed at all. No symbol in the injected library could receive a handler, and a new symbol in an injected library is a contract change: an app calling Okta.onMessage on a host at 21 does not fail gracefully — it dies on the device with Cannot find static method Okta.onMessage, naming a file its author never wrote. Hence 21 → 22.

What the number means for you in practice: an app declaring minContract: 22 is refused on an older host with an "update the app" screen — which is the correct refusal, because the alternative is a compile death on a television in a school. A host at 22 runs everything that came before exactly as it did; nothing in contract 21 changed.

Declare minContract: 22 only if you receive. An app that publishes but never listens stays on its current floor and keeps working on every device in the field — and raising the floor without needing to does not make your app newer, it makes it refuse devices it used to run on.

Publishing — POST /api/apps/realtime/publish

Behind the realtime.messages.write scope. Declare it in scopes like any other.

await Okta.post('/api/apps/realtime/publish', {
  'event': 'class.called',
  'data': {'section': 'A-3'},
});

The body takes two keys and no others: event and data. A 202 answers:

{
  "published": true,
  "channel": "private-app.01M1TTHDSWKEXCCATPVA7MP8N5.roll-call",
  "sent_at": "2026-09-06T07:39:46+00:00"
}

And this is what reaches your surfaces on the channel:

{
  "event": "class.called",
  "data": {"section": "A-3"},
  "sent_at": "2026-09-06T07:39:46+00:00",
  "from": "server"
}

Your event name never becomes the broadcast event name. The broadcast event is the fixed string app.message for every message from every app, and class.called travels inside the payload under event. Broadcasting under your name would let an app broadcast device.command.queued — or any name a platform surface binds on — and a client would act on a message a partner wrote. One frame: the client binds once, and your vocabulary is data.

Refusals — and what to do about each

Status error What happened, and what you do
403 scope_not_granted realtime.messages.write is not granted to this installation. Declare it and republish; the tenant grants it on update.
422 invalid_event The event name does not match ^[a-z][a-z0-9_.:-]{0,63}$ — lowercase, starting with a letter, at most 64 characters.
422 payload_too_large The data block exceeded 8192 bytes of JSON. Send a reference and let the surface fetch the rest over the API.
429 rate_limited You exceeded 60 messages a minute for this installation. The body carries retry_after in seconds, and the response carries a Retry-After header too.

The refusal text names the fix, not just the fault:

{"error":"payload_too_large","message":"The `data` object is 9008 bytes of JSON; the limit is 8192. Send a reference and let the surface fetch the rest over the API."}
{"error":"rate_limited","message":"This installation has published 60 messages in the last minute. Retry in 48s, or batch what you are sending into fewer messages.","retry_after":48}

The two limits, and why they sit where they do

8 KB on data, so the channel does not become a second data path: anything that needs data reads it from the API with its usual scope, which keeps scope enforcement on one door. Send "section A-3 was called", not the section roster.

60 messages a minute per installation — that is, per (school, app) pair. Not per app and not per school, because the pair is exactly the blast radius of a loop: a per-app limit would let a runaway loop in one school's copy silence your app in every other school, and a per-school limit would let one chatty app spend every other app's budget.

What is deliberately absent from the body

No tenant_id, no module. Tenant and module are read from the server-side installation context alone, and there is no place in the body to write them. Such a field would be an assertion, and honouring it would let an app installed in one school address another school's screens — the one thing the whole installation model exists to prevent. Send them if you like; they change nothing.

And no Idempotency-Key. That header replays a stored response for 24 hours so a write does not happen twice, and nothing is written here. An instant message served from a cache is a message that did not arrive while the caller is told it did — worse than a visible error.

A publish whose broadcast fails still returns 202 and logs a warning. The caller already did the real work — recorded the attendance, called the class — and the message only announces it; failing the request would undo something that happened for the sake of an announcement that did not.

Receiving — Okta.onMessage (contract 22)

Okta.onMessage((String event, Map data) {
  if (event == 'class.called') {
    setState(() { _section = '${data['section']}'; });
  }
});

One handler; a second call replaces the first. There is no handler list and no unsubscribe, and both absences are deliberate:

  • A handler list asks a question the sandbox cannot answer: which of them owns this message? One app, one channel, one handler.
  • Unsubscribing has nothing to do: the handler dies with the mounted mini-app, because the host opens the socket on mount and closes it on teardown. An unsubscribe symbol would add exactly one thing — a way for you to get the lifetime wrong and go silent while still on screen.

And there is no send symbol. Okta.post has sent since contract 1, and a second symbol for the same act is a second write path that drifts.

The socket belongs to the mounted app alone. Each target gets its own delegate, and each delegate its own socket and channel — so there is no shared socket for anything to leak through, and no app hears another's messages.

The channel name comes from the launch, not from the bundle. The bundle is reused for as long as payload_version holds, so a channel carried on it would be the channel of whichever tenant opened it before the school was switched — a socket that authenticates cleanly and delivers another school's messages. The launch is re-asked on every open, and the host takes the channel from there. You never touch any of this: you call onMessage and nothing else.

Listening needs no scope — the installation is the permission. A scope is what a school grants an app so it can reach the school's data; this channel carries only what your app itself sent to its own surfaces, in the school that installed it. What must hold is that the installation is live and active, and that is re-checked on every subscribe: uninstalling or suspending closes the channel. That is why there is no realtime.messages.read and never will be — a grant that is requested and buys nothing is a grant partners think they need.

The rule that saves your app: never wait for a message in order to draw

A dead socket changes nothing on screen. The realtime block on the launch payload is optional, and its absence is a normal state, not a fault: no broadcaster deployed, a server older than the feature, a platform where the socket does not open. Every failure after that is swallowed and logged: refused auth, dead host, malformed frame, a handler that throws.

And from inside the sandbox, a host that never calls your handler and a silent channel are the same thing — you have no way to tell them apart. So there is one rule:

Read state with Okta.get, then let messages update it. An app that draws an empty screen and waits for its first message shows that emptiness forever on any device outside coverage — which is exactly what a television in a school with a flaky network looks like.

A socket that never connects costs you freshness and nothing else. And the host retries for you: backoff starting at 2 seconds, doubling to a ceiling of 5 minutes, with ±20% jitter, and the counter resets on a successful subscribe — not on a successful connect.

Do not throw inside the handler. It is interpreted code invoked from a socket callback — outside any build, so there is no error boundary beneath it. The host wraps it twice as a backstop, but keep the body defensive: read from data carefully, and assume no key.

And as the publisher: emit every field, null when empty. A key merely missing from the decoded map is a raw null in dart_eval — is throws on it, and even OktaJson.str throws. Keep your payload a fixed shape and fill the gaps with an explicit null. You are both ends here, so the contract is between you and yourself.

A worked example: "class roll call"

One app, two surfaces: a teacher calls a section from their phone, and the classroom screen updates with no polling.

The phone surface — publishes after doing the real work:

Future<void> callSection(String section) async {
  // 1) The real work first: it is recorded on your own server.
  final res = await Okta.post('/api/roll-call/calls', {'section': section});
  if (res.status < 200 || res.status >= 300) {
    Okta.toast('Could not record the call');
    return;
  }

  // 2) Then the announcement. Its failure does not undo (1).
  await Okta.post('/api/apps/realtime/publish', {
    'event': 'class.called',
    'data': {'section': section, 'called_at': '${DateTime.now()}'},
  });

  Okta.playSound('success');
}

The screen surface — draws from state, then messages update it:

class Board extends StatefulWidget {
  const Board({super.key});
  @override
  State<Board> createState() => _BoardState();
}

class _BoardState extends State<Board> {
  String _section = '';

  @override
  void initState() {
    super.initState();

    // First: draw what is true now. This is the screen on a device
    // whose socket never connected — and it is a correct screen.
    _loadCurrent();

    // Then: let the message update it. Nothing here gates the draw.
    Okta.onMessage((String event, Map data) {
      if (event != 'class.called') return;
      final dynamic v = data['section'];
      setState(() { _section = '$v'; });
    });
  }

  Future<void> _loadCurrent() async {
    final res = await Okta.get('/api/roll-call/calls/current');
    if (res.status != 200) return;
    final dynamic v = res.json()['section'];
    setState(() { _section = '$v'; });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        _section.isEmpty ? 'No call right now' : 'Called: $_section',
      ),
    );
  }
}

And in your manifest — on the screen package alone, if that is the receiver:

"mobile": {
  "minContract": 22,
  "screen": {
    "enabled": true,
    "title": "Class roll call",
    "places": [
      { "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" }
    ]
  }
}

Note what is not in the example: no polling loop, no Timer, no Future.delayed re-asking the question. And if the socket drops, the screen keeps whatever _loadCurrent() last read — stale, not blank.

The realtime block on the launch payload

The launch payload carries a realtime block (on the screen launch too) with the driver, key, host, port, channel name and auth endpoint. You do not use it — the host consumes it and opens the socket; it is mentioned here only because it explains why the channel comes from the launch and not from the bundle.

When the broadcaster is not configured the block arrives as null, not as a missing key: absence would force a client to conflate "server older than the feature" with "feature switched off" — two facts with two behaviours.

Audible announcements (audio & speech)

A student-call app announces a child's name when their guardian reaches the gate, and its settings offer the school four sounds: a gentle bell, the name spoken by a synthetic voice, a recording the school made, and a recording the guardian made. In a browser all four worked. On the two surfaces that matter — the display on the wall and the employee's phone — only the bell did, because the host's entire audio surface was Okta.playSound(name): a closed vocabulary of three system tones. Three of the four modes had no path at all.

Contract 23 opens that path with four calls.

The four, and two capabilities rather than one

Call What it does What false means
Future<bool> Okta.playAudio(String url) Play an audio file from a URL your own server serves Playback did not start: an invalid URL, a file that cannot be reached, or a speaker that will not respond
void Okta.stopAudio() Silence whatever is sounding — the file and the voice alike — (returns nothing, and cannot fail)
Future<bool> Okta.speak(String text) Speak a string in the device's voice Speech did not start: no engine, or no voice for the language asked for
Future<bool> Okta.canSpeak() Can this device speak? It cannot — and that is a sound answer, not a fault

Two capabilities, not one, because the modes are different things. The two recordings — the school's and the guardian's — need a file played (playAudio); only the synthetic voice needs speech (speak). So an app that offers recordings alone does not need a TTS engine to exist on the device at all, must not ask about one, and must not disable itself over its absence.

Ask canSpeak() before you offer "announce by name" — this is the point

Many Android TV boxes ship with no Arabic voice whatsoever. Without the probe your app picks the spoken mode, the wall goes silent, and nothing anywhere says why: no error on screen, no line in a log, and no visible difference between "this device has no voice" and "the call never arrived".

false is an ordinary state on a healthy device. It is not a fault, and not something to retry — asking again in two seconds answers false again until somebody installs a voice on that device. The correct response is to step down: the recording, then the bell.

Ask it in two places: when you draw your app's settings, so you never offer a school an option that cannot work on its screen; and at the moment of the announcement, so you never assume that what was true at configuration time is still true.

And the answer is not cached — not by the host, and not by you. A school that installs an Arabic voice on Tuesday uses it on Tuesday; a wall display is not restarted for months, so an answer cached at startup means an installed voice goes unused until the next holiday.

On Windows the host resolves the voice by listing the engine's languages (the per-language probe is Android-only) and returns the engine's own spelling of the tag. You do none of this: the part that concerns you is that canSpeak answers truthfully on both platforms.

Nothing throws — every answer is a bool

A dead speaker, a 404 on the recording, a missing engine: all false. None of the four raises an exception up to you.

That is deliberate, because the audience is a wall: on a display with nobody standing at it, an exception is a red screen nobody clears until the school day ends. So check the returned value, and do not wrap the call in a try.

playAudio takes a URL your own server serves

The file is fetched by the platform's audio player, never by the authenticated client: no bearer and no tenant header rides to a partner file host. The URL therefore reaches only what the device could have fetched anonymously, and the call hands back a bool, not bytes.

In practice:

  • Absolute http(s) only; file:/// and its friends are refused.
  • The file must be served without Okta authentication. A short-lived signed URL your own server issues is a sound answer; an endpoint expecting an Okta token is not — it will answer 401 and you will get false.
  • If the recording lives behind your app's own domain, expose it on a public path your server serves. Do not pass a relative /api/… path: this call is not Okta.get.

One voice in the hall

A second playAudio replaces whatever is sounding, and stopAudio() silences both the player and the voice. There are never two channels open at once.

There is one loudspeaker on a wall, and two names overlapping in a lobby is worse than one name arriving late. So if two calls land back to back, the later one is what is heard — and that is the behaviour you want by default.

true means "started", not "finished"

There is no completion signal on this surface. The Future resolves when the sound starts, so you cannot chain two calls today — "ring the bell, then speak the name" is not expressible, because await on playAudio does not wait for the clip to end.

A known limit, stated now rather than discovered as a queue of announcements trampling each other on a school wall. If you need a sequence, space it with a delay you choose, and know that it is an estimate.

And why false can arrive late

playAudio may answer false late when the server is unreachable: the player waits out its own preparation timeout.

This is a choice, not an oversight. A fast "no" followed by a delayed noise would make the wall play the fallback bell and the recording both — so answering quickly here would buy exactly what the section above prevents.

playSound is unchanged and is not replaced

The bell is a confirmation drawn from a closed, host-owned vocabulary (success · error · warning · chime, each with a haptic); this surface is an announcement. Two different things, which is why the first was not removed.

And the bell is what you fall back to when the device has no voice and the recording cannot be reached — present on every contract since 5. But success is the system click, and the system click makes no sound on Windows — which is to say on the gate computer itself. Call chime there.

chime — «a pupil went through and counted». A short two-note tone the Okta app ships itself, added because a valid pass on the gate computer was silent. success was not changed: that would have changed the sound of every published app at once, with nothing in any of them asking for it — an app that wants the tone says so.

  • Not a contract change. A word in the host's vocabulary, not a symbol in an injected library, so do not raise minContract for it and check nothing before calling it. A host older than the word treats it as an unknown name, which in the Okta app is the very click success plays — the right fallback, not a failure.
  • Never quieter than success. When the tone cannot play — no audio output, or an iPhone (one audio session per app there, so the click stays) — the click plays instead. On Android it respects silent mode, and it ducks whatever is playing under it rather than cutting it off.
  • Its player is its own: it does not cut an announcement from playAudio/speak, and stopAudio() does not silence it. A second call before it finishes starts it again from the top.
  • Use it for what it means and nothing else: «seen at the gate». A pass that counted — an arrival or a departure, and a late one too, because the pass counted; the text says the verdict, not the sound. A pupil who already counted earlier today and comes back to the camera or taps their card gets the same tone with a card that says «already» (what «حضور» does since 1.6.44, at schools' request: silence at the gate reads as «the gate stopped working») — not recorded again, and the colour or the text says which. Saves and settings stay on success: a tone heard on every save stops meaning anything at the gate. And a mechanical repeat (the same card still in the reader's range, a UHF reader reading whatever is in range several times a second) should not sound at all — keep it quiet in your code.

minContract: 23 — and when not to raise it

An app declaring minContract: 23 is refused on an older host with an "update the app" screen — which is the correct refusal, because the alternative is a compile death on a television in a school (Cannot find static method Okta.speak).

Do not raise the floor to 23 unless you actually call one of the four. An app that makes no sound does not move, and raising the floor without needing to does not make your app newer — it makes it refuse devices it used to run on.

An app that wants recordings only still needs 23: playAudio is new too, and anything new in an injected library is a floor, whatever it is for.

The simulator answers false to all four — deliberately

On the virtual device all four always answer false, even though a browser tab could play <audio> and use Web Speech.

The reason is that a simulator which always says canSpeak() == true never exercises the fallback path this chapter is asking you to write — and the first place you would find it missing is a hall full of parents. So use the simulator to test that your app steps down with no error message and no blank screen, then test the spoken path on a real device or a paired screen.

Example: the gate call

Probe, then speak; otherwise the school's recording; otherwise the bell. Note that every step falls silently to the one below it: no toast, no error message — because nothing that happened is an error.

// mode: 'voice' | 'recording' | 'bell' — a setting the school chooses.
Future<void> announce(String name, String mode, String recordingUrl) async {
  // Cut the previous call: one speaker, and two overlapping names are
  // worse than one late name.
  Okta.stopAudio();

  if (mode == 'voice') {
    final bool can = await Okta.canSpeak();
    if (can) {
      final bool spoke = await Okta.speak('Guardian arrived for $name');
      if (spoke) return;
    }
    // No voice on this device — step down, and report no fault, because
    // none occurred.
  }

  if (recordingUrl.isNotEmpty) {
    final bool played = await Okta.playAudio(recordingUrl);
    if (played) return;
  }

  // The bell: the floor nothing falls through — but it is the system click,
  // which makes no sound on Windows. In the Okta app on Windows, `chime` does.
  Okta.playSound('success');
}

And in your settings, do not offer what cannot work:

class _SettingsState extends State<Settings> {
  bool _voiceAvailable = false;

  @override
  void initState() {
    super.initState();
    _probe();
  }

  Future<void> _probe() async {
    final bool can = await Okta.canSpeak();
    setState(() { _voiceAvailable = can; });
  }

  // ... and in build: show "announce by name" only while _voiceAvailable,
  // and in its place a line saying this device has no voice installed.
}

Wired to an instant message — the screen listens, then announces, and the name comes from the payload:

Okta.onMessage((String event, Map data) {
  if (event != 'guardian.arrived') return;
  final dynamic v = data['student_name'];
  setState(() { _name = '$v'; });
  announce('$v', _mode, _recordingUrl);
});

And the previous chapter's rule still holds: never wait for a message in order to draw. An audible announcement sits on top of state you read with Okta.get; it is not a substitute for it.

No manifest key, and no scope

These are host symbols — not manifest keys and not grants. There is nothing to add to manifest.json and no scope to request from the school in order to speak or play a file: the sound leaves the speaker of the device your app was opened on, and reads nobody's data.

The only thing that moves is minContract — so do not go hunting for a key to add.

Voice notes and file uploads

Everything your app could send to your server was typed or photographed. Okta.uploadFile(path) has existed since contract 1, but the host's picker accepts five extensions (pdf, jpg, jpeg, png, heic), and nothing on this surface recorded audio at all. A teacher who wants to record a sentence for a parent, a guardian who wants to answer a dismissal call by voice, a supervisor who wants to attach the incident report the school already has as a .docx — none of the three had a way.

Contract 24 opens both: a microphone, and a picker you can tell what you want.

Call What it does
Okta.canRecord() Can this device record at all? Ask before you draw a record button
Okta.recordStart() Start recording. false = the host refused (no mic, permission denied, or a take already running)
Okta.recordStop() End the take and get an opaque handle (String?) naming it. null = there was nothing to end
Okta.recordCancel() Throw the take away and release the microphone. void, always safe
Okta.playRecording(handle) Let the person hear what they recorded before it goes
Okta.uploadRecording(path, handle) Upload the take to an API path — the same map uploadFile returns
Okta.uploadFileOfKind(path, kind) Pick and upload, with kind deciding what the picker offers

All of them are contract 24, so declare "minContract": 24 if you call any of them. If you call none of them, do not raise it: bumping without need turns away devices your app was working on.

Record → listen → send

Three verbs, and the middle one is why there are six symbols and not three: a voice note nobody can hear before it leaves is a voice note nobody sends twice.

String? _handle;
bool _busy = false;

Future<void> start() async {
  if (!await Okta.canRecord()) {
    Okta.toast('No microphone is available on this device');
    return;
  }
  if (!await Okta.recordStart()) {
    Okta.toast('Could not start recording');
    return;
  }
  setState(() { _busy = true; });
}

Future<void> stop() async {
  final String? handle = await Okta.recordStop();
  setState(() { _busy = false; _handle = handle; });
}

Future<void> play() async {
  final String? h = _handle;
  if (h == null) return;
  await Okta.playRecording(h);          // the same output playAudio uses
}

Future<void> send() async {
  final String? h = _handle;
  if (h == null) return;
  final dynamic result = await Okta.uploadRecording('/api/apps/notes', h);
  if (result == null) return;           // cancelled — does not happen here
  final int status = result['status'];
  if (status >= 200 && status < 300) {
    setState(() { _handle = null; });   // sent, so it cannot be sent twice
    Okta.toast('Note sent');
  } else {
    Okta.toast('Could not send: ${result['error']}');
  }
}

canRecord() before the button — this is the point

A classroom display has a speaker and no microphone. And on a phone the microphone permission can be permanently denied in Settings, where nothing your app does will ever raise a prompt again.

Without the probe you draw a record button, the user presses it, and nothing happens: no prompt, no error, nothing in any log. That is exactly the failure this probe exists to prevent — the canSpeak() lesson, word for word.

And false is an ordinary answer about a working device, not a fault to report or retry: offer typing, or the file the user already has, instead.

It does not request the permission, so it is safe to call while building a screen. recordStart() is the call that may raise the prompt.

A handle names one take, and is never reused

recordStop() returns an opaque string naming a take the host is holding — not the take itself: the sandbox has no filesystem, and the bytes never cross the bridge. There is nothing in it to read or parse; hand it to playRecording and uploadRecording and nothing else.

It is retired the moment another take starts, or your app closes. A retired handle is refused by name rather than standing for whatever the host is holding now — and that is the difference worth the extra argument: a parent hears their own voice played back and a different family's recording is what reaches the school.

So keep the handle for as long as its screen, and clear it after a successful send.

One take at a time

recordStart() on a host that is already recording answers false rather than silently discarding what is being said into the phone. And playRecording goes through the same audio output playAudio uses (contract 23): starting one silences the other, and Okta.stopAudio() cuts whichever is sounding — so you never have to know which it was.

uploadFileOfKind — and the vocabulary is closed

Okta.uploadFile(path) has not changed and will not: its five extensions stay. Widening it would have changed, silently, what every already-published app is handed by its picker, with nothing in its code saying so.

So if you want more, say which more:

kind What the picker offers
image The device's images
audio Audio files
video Clips
document pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv, rtf, odt, ods
any Everything

The list is closed, because the host draws the picker: a word it does not know could only degrade quietly into something else on every device in every school. An unknown kind comes back as {'status': 0, 'error': 'unknown file kind "…"'} — and the simulator refuses it too, so your typo is caught in a browser rather than in a school.

final dynamic picked = await Okta.uploadFileOfKind('/api/apps/files', 'document');
if (picked == null) return;                   // the user cancelled
if (picked['status'] >= 400 || picked['status'] == 0) {
  Okta.toast('Upload failed: ${picked['error']}');
  return;
}

document is a list of extensions rather than "everything" on purpose: a picker that offers everything is how someone attaches a 400MB video to a leave request and then waits for it on school wifi. Ask for any if you mean it.

null means "the user cancelled" — a failure is something else

This is the distinction to write into your code:

  • null ← the user closed the picker. Nothing to do, nothing to show.
  • a map with status: 0 or 4xx/5xx and an error ← a failure. Show it.

And uploadRecording never returns null for a retired handle or a host with no microphone: those are error results, because nobody cancelled anything — and an app that reads one as a cancellation drops a voice note believing it sent it.

Nothing throws

No microphone, a denied permission, a closed picker, a retired handle, a network that died mid-upload — all false, null, or an error result. Do not write try around any of the seven; whoever reads "this can fail" writes one, and the exception does not happen here.

Where it does not work, said honestly

  • Web (the browser build and the simulator): canRecord() answers false. The recording package on the web hands back a blob rather than a file, so a take could not travel the same upload leg.
  • The simulator: refuses all seven, and its file picker answers a cancellation, exactly as uploadFile has since the beginning. A simulator that always says yes never once exercises the fallback path you are being asked to write, and the first place you would find it missing is a screen in front of a real person.
  • Display screens (okta-screen): refuse all seven — a television on a wall has no microphone and nobody standing at it to pick a file.

So try the recorded path on a real device, and write the fallback that works when canRecord() answers false.

No manifest key, and no scope

Exactly as contract 23: these are host symbols. There is nothing to add to manifest.json and no scope to request from the school — the microphone is the microphone of the device your app was opened on, and the file goes to an API path your usual scopes already govern. The only thing that moves is minContract.

The fixed shape every app wears — the frame the host draws

Everything that opens on Okta — platform screens and your mini-app alike — sits in one frame: a dark header 56 tall in both modes, the page below it with two rounded top corners on that same dark floor, a solid bottom bar pinned to the bottom edge at 72, and one open animation where the card you tapped becomes the screen.

And you publish nothing for it. The host draws the frame around you, and what changed inside okta_kit is the bodies of existing methods with not one signature moving — no new injected symbol, no new contract, and do not raise minContract. The app you have published today takes the shape on its first open on a host that carries it.

What the host draws around you

Layer Drawn by Note
The dark header (56) host and OktaAppBar lands on it colour for colour
The two rounded corners under it host it measures where your bar ends and clips the page below
The fixed header controls (language, theme, avatar) host a mini-app never draws these
The open animation host from the tapped card to the screen
Your app's canvas you in OktaPalette colours

The clip is conditional on your app having a bar: a Scaffold with no appBar gets no joint under it and no corners. If you want the frame, give your screen a bar.

Four lines, and that is your whole share

1 — OktaAppBar, not a raw AppBar in your own colours.

Scaffold(
  appBar: OktaAppBar.build("Today's attendance", palette), // or withActions / buildWithIcon
  body: content,
)

And if you build your own bar for some reason, take its two colours from the methods instead of writing the number yourself:

AppBar(
  backgroundColor: OktaAppBar.barBackground(palette),
  foregroundColor: OktaAppBar.barForeground(palette),
  ...
)

The header is dark in both modes and does not flip with the theme — that is the fixed shape itself. And palette.textStrong on that header is nearly black in light mode, i.e. ink that cannot be read on a dark strip; that is why barForeground exists.

2 — the bottom bar is OktaTabs.bottomBar, and that is the platform bar now.

Scaffold on this runtime accepts appBar, body and floatingActionButton and accepts no bottomNavigationBar — so the pinned bar is the last child of a Column whose body is Expanded:

final labels = <String>['Home', 'Reports'];
final icons = <IconData>[OktaIcons.home(), OktaIcons.chart()];

final rows = <Widget>[];
rows.add(Expanded(flex: 1, child: content));          // your screen body
rows.add(OktaTabs.bottomBar(labels, icons, _index, _onSelect, palette));

return Scaffold(
  appBar: OktaAppBar.build("Today's attendance", palette),
  body: Column(children: rows),
);

Write flex: 1 out. An omitted named argument arrives null on this runtime and is cast to int, so Expanded(child: …) on its own dies on the device — exactly the kind of failure reading the code never catches.

The bar comes out solid and flush: 72 tall, a hairline along its top, a 28×3 indicator above the selected tab rather than a fill behind it, icon 24 and label 11 — the host's own bar, cell for cell. bottomBarWithDots adds an unread dot to any tab flagged true.

3 — do not draw the corners or the header yourself. BoxDecoration(borderRadius:) is dropped by the bridge anyway, and a frame drawn per app is the thing one frame exists to prevent.

4 — and do not raise minContract. There is no new symbol here, and raising it with no need turns away devices your app was working on.

floatingBar stays, and is no longer the platform silhouette

If your app uses OktaTabs.floatingBar (contract 18) it keeps working and will not break — nothing was removed. But it is no longer the platform's shape: a floating bar leaves a band of the floor showing beneath it on wide windows, which is what pushed the unified shape to pin the bar to the edge and let the page run into it.

So if you want your app to read like the rest of the platform, switch the call to bottomBar with the composition above — same arguments, and floatingOver and floatingReserve go with it.

Okta's identity inside a mini-app (Liquid Glass)

The mobile app has adopted Liquid Glass as its visual language: the shell — app bar, sheets, tab bar, tenant cards — is a set of translucent surfaces that pick up what is behind them and catch light along their edge. This chapter says exactly which part of that is yours, which part is not, and why.

In one line: you do not build the glass — you ask for it. The host draws it through package:okta_glass, and it draws it with its own parts, so when Okta restyles the material your app changes with the next host release without you publishing anything.

The split: what we draw, what you draw

Layer Drawn by Note
The purple field behind everything host You cannot reach it and do not need to
The app bar above your app host (or OktaAppBar) Dark and solid in both modes — the fixed shape above
The permissions sheet, banners host They appear over your screen without asking you
Your app's canvas you In the material via OktaGlass, or solid via OktaSurface
Your cards, rows and buttons you In OktaPalette colours

package:okta_glass — the material, ready made

import 'package:okta_glass/okta_glass.dart';

Scaffold(
  backgroundColor: const Color(0x00000000),
  appBar: OktaAppBar.build("Today's attendance", palette),
  body: OktaGlass.background(          // the field, behind the whole screen
    ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        OktaGlass.card(                // a card-weight sheet
          OktaList.row(OktaIcons.calendar(), 'First period', '7:30', palette),
        ),
      ],
    ),
  ),
)
Call Weight When
OktaGlass.card(child) blur 22 · radius 20 · elevated A card — what you want most of the time
OktaGlass.chrome(child) blur 30 · radius 26 Something that floats over content moving beneath it
OktaGlass.field(child) blur 18 · radius 16 · flat An input — it has to read as recessed
OktaGlass.surface(r, b, e, child) yours Only when you must
OktaGlass.background(child) — The field behind your body

One field per screen, never one per card. The field is what makes every sheet agree about where the light comes from; a field per card gives each one its own sun.

Two layers maximum. A third loses the edges their contrast and everything turns to fog.

Why the host draws it and you do not

The bridge does not have the tools. Glass is five layers, and three of them do not cross dart_eval:

  • BackdropFilter and ImageFilter.blur are not in the bridge's vocabulary at all — no blur.
  • BoxDecoration carries color and boxShadow only; gradient is commented out of the bridge declaration, and an undeclared named argument is silently dropped. So the raked tint compiles cleanly and never reaches Flutter — you get a flat rectangle with not one error, and validate.dart does not catch it because nothing failed.
  • BoxDecoration(border: …) does not compile at all — no specular rim.

And that indirection is the feature, not the cost. The drawing lives in the host's binary, not in your bytecode. So when Okta restyles the material — its colour, its blur, its edge — the change reaches every published mini-app on the next host release: no rebuild, no new version, not one line you edit. A partner who hand-rolled the look from ClipRRect and ColoredBox is frozen at the day they shipped.

package:okta_glass needs min_contract: 16 or higher. Declare it in the manifest, or compilation fails on a device carrying an older host.

And a contract's features are only real once a host build carrying them reaches the user's device. Raising min_contract ahead of that does not make your app newer — it makes it refuse to run on every device in the field until the store update lands. Check the published host contract in the partner dashboard before you depend on it.

The palette — the single source of colour

Never write a colour literal. Theme.of(context).colorScheme does not cross the bridge, so the palette is your only route to the host's identity:

import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';

Widget main() => const HomeScreen();

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // Build it once at the root and pass it down. Do not rebuild it per widget.
    final palette = OktaPalette.of(Okta.isDark());

    return Scaffold(
      backgroundColor: palette.surface,
      appBar: OktaAppBar.build("Today's schedule", palette),
      body: /* … */,
    );
  }
}
Role Field Use it for
Brand palette.brand Primary buttons, the selected tab, the accent icon
Brand soft palette.brandSoft Touch ripple, hover wash
On brand palette.onBrand Text and icons on top of brand
Ground palette.surface The screen background — Scaffold.backgroundColor
Raised surface palette.surfaceRaised Cards and blocks sitting on the ground
Strong text palette.textStrong Headings and body
Muted text palette.textMuted Descriptions and secondary data
Border palette.border Hairlines

brand is not the same colour in both modes: #6D428F in light, #A57BBA in dark. Saturated brand purple over a near-black ground glows and hurts legibility. Read it from the palette; never hard-code it.

These values mirror OktaTokens in the mobile app, and a test there — okta_kit_palette_parity_test.dart — fails the build if the two copies drift. What you read here is what actually runs on the device.

Recipes that give you Okta's shape despite the bridge

The route to the host's look is not copying its widgets — it is matching its numbers with the tools that work.

A rounded card. Not BoxDecoration(borderRadius:) — it is silently dropped and paints square corners. Corners come from ClipRRect:

Widget card(OktaPalette palette, Widget child) {
  return ClipRRect(
    borderRadius: BorderRadius.circular(16),  // radiusXl — the card radius
    clipBehavior: Clip.antiAlias,
    child: ColoredBox(
      color: palette.surfaceRaised,
      child: Padding(
        padding: const EdgeInsets.all(16),    // space4
        child: child,
      ),
    ),
  );
}

A hairline. Not Border.all — it does not compile. A hairline is a box one pixel tall:

Container(height: 1.0, color: palette.border)

Elevation — and a trap worth knowing. boxShadow is supported, but a shadow and a rounded corner cannot coexist: rounding works only through ClipRRect, shadows only through BoxDecoration, and BoxDecoration carries no borderRadius. So a shadow behind a rounded card is drawn square and shows at every corner.

Either a flat rounded surface separated by its colour (OktaSurface.card does this), or OktaGlass.card, which gets blur, edge and shadow at once because the host draws it. A hand-rolled shadow is only ever right on a square-cornered box.

The primary button — built, not configured. ElevatedButton and TextButton do not accept style on this engine; the parameter is not declared in the bridge, so your colour is silently dropped and the button arrives in the default grey. The button that carries your identity is assembled:

Widget primaryButton(OktaPalette palette, String label, void Function() onTap) {
  return GestureDetector(
    onTap: () => onTap(),        // a closure, not a function reference
    child: ClipRRect(
      borderRadius: BorderRadius.circular(999),
      clipBehavior: Clip.antiAlias,
      child: ColoredBox(
        color: palette.brand,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
          child: Center(
            child: Text(
              label,
              style: TextStyle(
                color: palette.onBrand,
                fontSize: 14,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
        ),
      ),
    ),
  );
}

Use ElevatedButton for a secondary action whose colour does not matter, and this for the primary one.

Text alignment. Text does not accept textAlign here, and TextAlign is not a bridged type at all. Alignment comes from Center or Align around the text, not from inside it.

The numbers that make you look like us. Match them; do not invent others:

Value
Card radius 16 — capsule 999
Card padding 16, compact 12
Spacing rhythm multiples of 4: 4 · 8 · 12 · 16 · 24 · 32
Text sizes 12 faint · 14 secondary · 16 body · 18 section title · 20 screen title
Weights 400 body · 500 label · 600 heading — never 700
Motion 150 fast · 220 base · 320 slow

Motion. Use package:okta_motion — its easeOutCubic is the host's own curve, so your app does not feel faster or slower than the screen it opened from:

import 'package:okta_motion/okta_motion.dart';

OktaMotion.entrance(index * 40, 220, card(palette, row))

entrance(delayMs, ms, child) is the same stagger the home screen's cards enter with: 40ms between one row and the next. Cap the delay at ten rows — the tail of a long table must not arrive two seconds late.

Icons. OktaIcons.* only. Arbitrary Icons.* constants are not bridged, and worse, the app is built with --no-tree-shake-icons precisely because your code points are chosen at run time: an icon outside the set can reach the device as an empty box with no error at all.

Text direction

The host is Arabic-first. AlignmentDirectional is not bridged, so direction comes from Directionality and OktaText:

final locale = Okta.locale();

Directionality(
  textDirection: OktaText.isRtl(locale) ? TextDirection.rtl : TextDirection.ltr,
  child: /* … */,
)

Text(OktaText.pick(locale, 'جدول اليوم', "Today's schedule"))

And one rule everyone forgets: identifiers, paths and version numbers stay LTR whatever the interface language — wrap them in Directionality(textDirection: TextDirection.ltr) or 04:A2:9F flips on screen.

Dark mode is not optional

Okta.isDark() follows the system appearance on the user's device. An app that reads the palette once at its root gets both modes for free; an app that writes Color(0xFF...) once is right in one mode and wrong in the other — and that is the single most common reason a visual review is rejected.

Pre-publish checklist

  • No colour literal anywhere in the code — all of them from palette.*
  • Scaffold.backgroundColor = palette.surface
  • Cards on palette.surfaceRaised, corners from ClipRRect not BoxDecoration
  • No Border.all, no BoxDecoration(gradient:) — the first does not compile, the second is dropped in silence
  • No hand-built glass — use OktaGlass (and min_contract: 16)
  • No shadow behind a rounded corner — it draws square
  • No ElevatedButton(style:), no Text(textAlign:) — both are dropped in silence
  • Components from OktaButton/OktaSurface/OktaList/OktaStates, not hand-built
  • Icons from OktaIcons exclusively
  • Opened in both light and dark, and the text read in both
  • Identifiers and paths wrapped LTR
  • All three states handled explicitly: empty, loading, error

A ready prompt for your coding assistant

Paste it verbatim into Claude Code, Cursor or Codex before asking for a screen:

You are building a Dart mini-app that runs inside the Okta mobile app,
compiled on the device by dart_eval. Follow this literally:

Identity:
- Every colour from OktaPalette.of(Okta.isDark()) — no literal
  Color(0xFF..).
- Ground palette.surface, cards palette.surfaceRaised, text
  palette.textStrong / palette.textMuted, hairlines palette.border.
- Card radius 16, capsule 999, padding 16, spacing in multiples of 4.
- Text sizes 12/14/16/18/20, weights 400/500/600 only.
- Icons from OktaIcons only.
- Components from okta_kit, never hand-built: OktaButton.primary/secondary,
  OktaSurface.card/hairline/pill/iconTile, OktaList.row/sectionHeader,
  OktaStates.loading/empty/failure, and the numbers from OktaMetrics.
- Motion from package:okta_motion, durations 150/220/320.

Engine limits — every one of these breaks:
- BoxDecoration carries color and boxShadow only. borderRadius and
  gradient are silently dropped: round corners with ClipRRect + ColoredBox.
- BoxDecoration(border:) does not compile: a hairline is
  Container(height: 1.0).
- No BackdropFilter, no ImageFilter. The material comes from
  package:okta_glass: OktaGlass.card / chrome / field / background
  (needs min_contract 16).
- A shadow and a rounded corner cannot coexist — use OktaGlass.card to lift.
- Buttons: ElevatedButton and TextButton only, and **neither accepts style**:
  build the primary button from GestureDetector + ClipRRect +
  ColoredBox(palette.brand).
- Text does not accept textAlign: align with Center or Align around it.
- Layout: Row, Column, Expanded — and always pass flex:. No Wrap, no
  AlignmentDirectional.
- No nested loops at all — flatten hierarchical data into one list.
- Callbacks are closures: onPressed: () => f() not onPressed: f.
- Keep JSON dynamic end to end; never give the receiver a Map type.
- No State.mounted, and no Timer (but DateTime, Duration and Future.delayed
  exist — a drain loop calls itself again after await Future.delayed(d), with
  your own stop flag). Never pass a callback to Future.delayed: it compiles
  and is never called.
- GestureDetector, not InkWell — and InkWell, InkResponse and SafeArea
  compile and then throw UnimplementedError.

What kills the app on a user's phone — follow this literally:
- Identity through the scalars: Okta.locale() / tenantId() / roleId() /
  isDark(). Okta.context() is a map with snake_case keys; .locale on it does
  not exist.
- Okta.uploadFile, location and preciseLocation return maps: ['file_name'],
  ['latitude'] — not .fileName, not .latitude.
- Never read a possibly-absent key: make the server always emit every field
  (null when empty). A missing key yields a raw null; `is` throws on it and
  `== null` lies — and no containsKey guard (tried and reverted). OktaJson
  protects the shape, not absence. The one test that answers absence is
  identical(value, null) — ask it before calling, interpolating or storing
  the value.
- setState with a block body: setState(() { x = y; }), never
  setState(() => x = y).
- Never read a bool straight out of a list: flags[i] == true.
- Never initialise a loop counter from a parameter, and never pass a number
  through a parameter into a bridged constructor.
- Never nest ternaries — branch and return.
- A JSON element does not survive ?: — rows.isEmpty ? null : rows[0]
  unboxes it and the next OktaJson dies. Assign inside an if, or read
  inside the branch.
- Before any call whose contract is above 5: guard with
  Okta.contract() >= N or declare minContract. Calling above the contract
  does not fail — it corrupts the interpreter for the rest of the session.
- list.sort() with no comparator throws: always pass the comparator.

Direction: Arabic-first via Directionality and OktaText.isRtl; identifiers
and paths stay LTR.

Handle all three states explicitly: empty, loading, error. Check res.ok,
and distinguish status == 0 (never reached a server) from a failed server
response.

Run dart run tool/audit_bridge_usage.dart lib/main.dart before publishing — it catches silently dropped parameters and the three traps above before a user does.

The MCP server's component_catalog tool serves the same set live from the platform — if your assistant is connected to it, ask before assuming.

Example: an attendance-by-scan app (native) — from nothing to published

One practical chapter that builds a complete Dart mini-app: a supervisor stands at the gate, waves student cards past the phone, and every read is recorded as an attendance — and kept on the device when the network drops. A real example, because it is what actually gets built, and because it touches everything a native app needs: state, pages, permissions, scanning, the network, storage, the three states, and the identity.

Before you write a line: four limits that shape the design

These are not preferences. Each one changes what your app can be.

1. There is no text input. TextField is not in the bridged surface. A native app is a read-and-act app: it shows, it scans, it taps, it sends. Any screen whose whole point is that someone types into it — a registration form, free-text search, a comment — is not a native app; build it in webview or external mode. Discovering this two weeks into the work is the most expensive lesson on the platform, which is why it is the first line here.

2. There is no navigation. Navigator.of is declared in the bridge with no implementation and no static dispatch entry, so the NavigatorState can never be obtained from inside the sandbox, and there is no route stack there either. Two screens inside your app means one piece of state holding a page number — the full pattern is below.

3. No nested loops. dart_eval 0.8.5 throws a compile-time RangeError when a loop body contains another loop — directly, or through a function it calls. Flatten hierarchical data into one list, on the server where you can.

4. A shadow and a rounded corner cannot coexist. Rounding works only through ClipRRect, shadows only through BoxDecoration, and BoxDecoration carries no borderRadius. When a surface has to lift and be round, use OktaGlass.card — the host draws that one natively and it gets all three.

0 · Layout and manifest

okta_app/native/attendance/
├── pubspec.yaml            ← pins okta_miniapp — do not bump the ref yourself
├── analysis_options.yaml
├── lib/
│   └── main.dart           ← the entry point: Widget main()
└── tool/
    └── validate.dart       ← the same gate CI runs

And the manifest:

"mobile": {
  "supported": true,
  "mode": "native",
  "entry": "okta_app/native/attendance/lib/main.dart",
  "minContract": 16
}

minContract: 16 because we use package:okta_glass. Nothing else to declare: device access needs no manifest entry since the capability gate was removed.

1 · State, and the trap that costs a day

StatefulWidget and setState work. Two traps:

setState(() { _busy = true; });        // block body
setState(() => _busy = true);          // boxes twice and throws

An arrow closure returns its body's value, and an assignment is an expression — so the value is boxed once to write the field and again to return it, and the second box lands on something already boxed: type '$bool' is not a subtype of type 'bool'. Convert every site, not only the ones that crash: whether the double box lands turns on register allocation, not on anything visible in the source.

The second: there is no State.mounted — it is unbridged, and naming it fails to compile. Call setState directly.

2 · Internal pages — the pattern, without Navigator

A page is a number in state, the bar decides what it shows, and the back button goes back internally and then hands over to the host when the pages run out:

class _AttendanceState extends State<Attendance> {
  int _page = 0; // 0 = scan · 1 = today's log

  void _openLog() {
    setState(() { _page = 1; });
  }

  // One back button for every page. It goes back internally while there is
  // somewhere to go, and hands over to the host on the first page — which is
  // the only way out of a mini-app.
  void _back() {
    if (_page != 0) {
      setState(() { _page = 0; });
      return;
    }
    Okta.close();
    // Doing nothing is a valid host response to close (there may be nothing to
    // pop), so never rely on it to stop execution — return straight after.
    return;
  }

  AppBar _bar(OktaPalette palette) {
    if (_page == 0) {
      return OktaAppBar.build('Attendance', palette);
    }
    return OktaAppBar.withBack(
        "Today's log", palette, Okta.appIcon(22.0), () => _back());
  }
}

Do not put the icon in leading on the first page. Flutter inserts a back button only when leading == null, so OktaAppBar.buildWithIcon silently removes the only way out. withBack takes the slot back for the button and moves the icon into actions.

3 · Scan — and read what comes back

There is no permission call to make first: the platform's capability gate has been removed, so a scan is just a scan. What has NOT changed is that it can come back empty, and that is the part worth writing code for.

Future<void> _scan() async {
  final key = await Okta.scanNfc();
  if (key == null) {
    // null covers every ending that is not a card: the user cancelled, the
    // phone has no NFC, the OS permission dialog was declined. None of them is
    // an exception, and none of them should be announced as an error.
    return;
  }
  await _submit(key);
}

4 · Sending — and three endings, not two

OktaApiResponse carries status, body, error and the computed ok. And the difference everyone forgets: status == 0 means the call never reached a server at all. Showing "the server returned an error" then is a lie to the supervisor — the server said nothing.

Future<void> _submit(String key) async {
  final res = await Okta.post(
    '/api/attendance/reads',
    <String, dynamic>{'key': key},
  );

  if (res.ok) {
    Okta.playSound('success');
    // body is dynamic end to end — never give the receiver a Map type.
    final name = OktaJson.strOr(res.body, 'student_name', key);
    setState(() { _log.add('$name · present'); });
    return;
  }

  if (res.status == 0) {
    await _queue(key);
    return;
  }

  Okta.playSound('error');
  setState(() { _log.add('refused · ${res.status}'); });
}

5 · The local queue — and the silent failure

Future<void> _queue(String key) async {
  await Okta.storePut('queued:$key', key);
  Okta.playSound('warning');

  // Say it out loud. The local capture is confirmed before the network is
  // consulted — which is right, it is how a scan survives an outage — but it
  // means an upload that never arrived looks **exactly** like one that did.
  // Someone standing at a gate is looking at the student, not at a badge in a
  // header, so the difference is told with a sound, a message and a log line.
  Okta.toast('Saved on the device — no network. It will send when it returns.');
  setState(() { _log.add('$key · waiting for the network'); });
}

Okta.storeKeys() and Okta.storeDelete(key) drain the queue later.

Storage is not secure storage. Treat it as readable by anyone holding the device: it is for a capture queue, not for a token.

6 · The list and the three states

Widget _logPage(OktaPalette palette) {
  if (_loading) {
    return OktaStates.loading(palette);
  }
  if (_log.isEmpty) {
    return OktaStates.empty(
      OktaIcons.list(), 'No reads yet', 'Wave a card to start.', palette);
  }

  // One loop, and a row builder with no loop inside it. A loop within a loop —
  // directly or through a function it calls — fails compilation with RangeError.
  final rows = <Widget>[];
  for (final entry in _log) {
    rows.add(OktaGlass.card(
      OktaList.row(OktaIcons.check(), entry, 'Today', palette),
    ));
  }

  return ListView(
    padding: const EdgeInsets.all(OktaMetrics.space4),
    children: rows,
  );
}

7 · Identity — without one colour literal

final palette = OktaPalette.of(Okta.isDark());

Scaffold(
  backgroundColor: const Color(0x00000000),
  appBar: _bar(palette),
  body: OktaGlass.background(          // one field for the whole screen
    _page == 0 ? _scanPage(palette) : _logPage(palette),
  ),
)

See "Okta's identity inside a mini-app" above for the detail. The rule here is one line: every colour from palette, every number from OktaMetrics, every component from okta_kit — so when Okta restyles the material your app changes without you publishing anything.

8 · Validate and publish

flutter test tool/validate.dart                       # the same gate CI runs
dart run tool/audit_bridge_usage.dart lib/main.dart   # catches the silently dropped

flutter analyze will complain that Okta and OktaPalette are undefined — that is expected. The libraries are injected by the engine at compile time and are not real pub packages.

Then create a new version. The on-device bytecode cache is keyed by (slug, entry, payloadVersion, runtimeSignature) and never by the content of the bundle: reinstalling without moving any of those keeps the old copy, with no message and no warning — you open the app, see the old behaviour, and conclude your fix did not work, when it never reached the device at all. See "The mini-app is cached" above for what each part means.

The whole file

import 'package:flutter/material.dart';
import 'package:okta_glass/okta_glass.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';

Widget main() => const Attendance();

class Attendance extends StatefulWidget {
  const Attendance({super.key});

  @override
  State<Attendance> createState() => _AttendanceState();
}

class _AttendanceState extends State<Attendance> {
  int _page = 0;
  bool _busy = false;
  final List<String> _log = <String>[];

  void _openLog() {
    setState(() { _page = 1; });
  }

  void _back() {
    if (_page != 0) {
      setState(() { _page = 0; });
      return;
    }
    Okta.close();
    return;
  }

  Future<void> _scan() async {
    if (_busy) {
      return;
    }
    setState(() { _busy = true; });

    final key = await Okta.scanNfc();
    if (key == null) {
      setState(() { _busy = false; });
      return;
    }

    final res = await Okta.post(
      '/api/attendance/reads',
      <String, dynamic>{'key': key},
    );

    if (res.ok) {
      Okta.playSound('success');
      final name = OktaJson.strOr(res.body, 'student_name', key);
      setState(() {
        _log.add('$name · present');
        _busy = false;
      });
      return;
    }

    if (res.status == 0) {
      await Okta.storePut('queued:$key', key);
      Okta.playSound('warning');
      Okta.toast('Saved on the device — no network. It will send when it returns.');
      setState(() {
        _log.add('$key · waiting for the network');
        _busy = false;
      });
      return;
    }

    Okta.playSound('error');
    setState(() {
      _log.add('refused · ${res.status}');
      _busy = false;
    });
  }

  AppBar _bar(OktaPalette palette) {
    if (_page == 0) {
      return OktaAppBar.build('Attendance', palette);
    }
    return OktaAppBar.withBack(
        "Today's log", palette, Okta.appIcon(22.0), () => _back());
  }

  Widget _scanPage(OktaPalette palette) {
    return ListView(
      padding: const EdgeInsets.all(OktaMetrics.space4),
      children: <Widget>[
        OktaGlass.card(
          Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Text(
                'Hold the student card against the phone',
                style: TextStyle(
                  color: palette.textStrong,
                  fontSize: OktaMetrics.textBody,
                  fontWeight: FontWeight.w600,
                ),
              ),
              const SizedBox(height: OktaMetrics.space4),
              _busy
                  ? OktaButton.disabled('Scanning…', palette)
                  : OktaButton.primary('Start scanning', () => _scan(), palette),
            ],
          ),
        ),
        const SizedBox(height: OktaMetrics.space3),
        OktaSurface.cardTappable(
          palette,
          () => _openLog(),
          OktaList.rowWithTrailing(
            OktaIcons.list(),
            "Today's log",
            'Everything recorded since the day began',
            OktaSurface.pill('${_log.length}', palette.brandSoft, palette.brand),
            palette,
          ),
        ),
      ],
    );
  }

  Widget _logPage(OktaPalette palette) {
    if (_log.isEmpty) {
      return OktaStates.empty(
          OktaIcons.list(), 'No reads yet', 'Wave a card to start.', palette);
    }

    final rows = <Widget>[];
    for (final entry in _log) {
      rows.add(Padding(
        padding: const EdgeInsets.only(bottom: OktaMetrics.space2),
        child: OktaGlass.card(
          OktaList.row(OktaIcons.check(), entry, 'Today', palette),
        ),
      ));
    }

    return ListView(
      padding: const EdgeInsets.all(OktaMetrics.space4),
      children: rows,
    );
  }

  @override
  Widget build(BuildContext context) {
    final palette = OktaPalette.of(Okta.isDark());

    return Directionality(
      textDirection: OktaText.isRtl(Okta.locale())
          ? TextDirection.rtl
          : TextDirection.ltr,
      child: Scaffold(
        backgroundColor: const Color(0x00000000),
        appBar: _bar(palette),
        body: OktaGlass.background(
          _page == 0 ? _scanPage(palette) : _logPage(palette),
        ),
      ),
    );
  }
}

Example: Daycare Operations app (External)

A self-contained worked example showing how to build an External app that targets daycare center tenants (daycare_center) — the new tenant type added to the Okta platform for leaf operating entities.

Why External and not Embedded?

Daycare centers need operationally-specific features: attendance check-in/check-out, daily reports pushed to guardians, and authorized pickup management. These are short-lived (daily) records that typically live in the partner's own store and integrate with third-party messaging or biometric hardware. External is the right fit because:

Reason Detail
Operational data stays with the partner Attendance logs, daily reports, and pickup lists live in the partner's store, not in okta-web.
Independent stack The partner may use biometric readers, RFID scanners, or existing mobile apps — none of which can be shipped inside okta-web.
Real-time guardian notifications The partner's own messaging channels (WhatsApp, Push, SMS) are already configured on their infrastructure.
Full stack freedom Any language or framework, without Embedded isolation constraints.

Attendance records, daily reports, and pickup data are not stored in okta-web and are not defined as partner scopes — they are operational data owned entirely by the partner app. Scopes are used only to read the okta-web data needed for correlation (student roster, guardian contacts).

Tenant type daycare_center

The platform has added the daycare_center tenant type to the shared canonicalTenantTypes catalog, which is pushed from okta-web to okta-partners over the bridge. This means:

  • The type appears automatically in the partner portal — no action needed on your side.
  • You can declare daycare_center as a primary target tenant type when creating your app.
  • daycare_center tenants can install your app and approve the requested scopes, just like any other tenant type.

No additional code is required to handle this type — once your app is published, the platform surfaces it to eligible tenants.

Required scopes

A daycare operations app needs to read student data from okta-web to correlate it with its own operational records. Pick scopes from the scope picker in the partner portal — the catalog is a mirror of okta-web, kept in sync automatically in the partner_available_scopes table. Do not hard-code scope names in your code; what appears in the picker is the authoritative source.

Scope Why
education.students.read Read the enrolled children for each tenant and match them with local attendance records.
education.students.write Optional — only if the app needs to write back a custom field (e.g. an RFID card number). Request it only when needed.

Least-privilege principle: start with education.students.read only. If you later need to write, add education.students.write in a new version with a clear reason in the manifest.

There is no attendance or daily-report scope because that data lives in your app's own store, not in okta-web.

Manifest

{
  "moduleId": "daycare-ops",
  "displayName": "Daycare Operations",
  "version": "1.0.0",
  "category": "operations",
  "integrationType": "external",
  "description": "Attendance check-in/out, daily guardian reports, and authorized pickup management for daycare centers.",
  "scopes": [
    {
      "key": "education.students.read",
      "required": true,
      "reason": "Match tenant children with attendance and pickup records"
    }
  ],
  "external": {
    "webhookUrl":    "https://daycare.example/okta/webhook",
    "webhookEvents": [
      "education.students.created",
      "education.students.updated",
      "partner.installation.token_rotated"
    ],
    "redirectUrls":  ["https://daycare.example/oauth/callback"]
  }
}

redirectUrls is useful if you want an OAuth-style flow after installation — redirect the tenant to /oauth/callback, receive the installation token, and run an initial roster sync.

Staying in sync via webhooks

After installation you receive subscribed events automatically. Verify the signature on every webhook (see Webhooks):

// Example: handling a student-update event
$event = $request->json('event'); // "education.students.updated"
$data  = $request->json('data.student');

match ($event) {
    'education.students.created' => $this->syncNewStudent($data),
    'education.students.updated' => $this->updateStudentRecord($data),
    'partner.installation.token_rotated' => $this->storeNewToken(
        $request->json('data.new_token')
    ),
    default => null,
};

return response()->json(['ok' => true]);

Respond 2xx within 10 seconds. For longer processing, return 202 immediately and handle via a background queue.

Recommended events for a daycare operations app:

Event When it fires
education.students.created A new child is added to the tenant
education.students.updated A student's data changes (name, contact, ...)
partner.installation.token_rotated The tenant rotated the token — store the new one immediately

Event names follow the canonical <feature>.<resource>.<action> format; do not invent custom names.

Initial sync after installation

On first install, page through the full student roster to seed your local store:

# First page
GET /api/apps/education/students?page=1&per_page=100
Authorization: Bearer <installation_token>

# Repeat until last_page is reached

After the initial sync, rely on webhooks for incremental changes. This minimises API calls and keeps your data real-time.

Guardian notifications

Daycare-specific notifications (arrival, departure, daily report) go out through the partner's own channels (WhatsApp / SMS / Push) — they are not part of the Okta notifications system.

If you later want to unify notifications with the Okta platform so tenants can pick channels from their dashboard, you can declare a notifications catalog for your app — see the Notifications catalog section.

Step-by-step summary

  1. Create an External app in the portal; declare daycare_center as a target tenant type.
  2. Request education.students.read from the scope picker (add write only if needed).
  3. Subscribe to education.students.created, education.students.updated, and partner.installation.token_rotated.
  4. Add a redirect_url if you want an OAuth-style initial-sync flow.
  5. On install: fetch the full roster via pagination, then rely on webhooks for ongoing changes.
  6. Submit for review — the Okta team checks the manifest and webhook URL, then publishes the app to the marketplace.

Printing on the school's letterhead

Every app that prints anything meets the same wall: Arabic shaping, RTL, a font that renders it, margins, and a header the school recognises as its own. Solved separately in each app it is solved differently in each app — a school ends up holding six documents in six layouts, none of them its letterhead, and the one thing it wanted, that these came from its school, is the thing none of them has.

The engine already knows all of it, because the school configured it once in the report builder.

The division of labour

The template is the frame, and the school owns it: logo, header, footer, watermark, margins, paper size, orientation.

The body is yours, and you pass it in. A template carries no content of its own.

How to use it

use App\Services\PartnerApi\Reports\Templates\ListReportTemplates;
use App\Services\PartnerApi\Reports\Templates\RenderReportTemplate;

// 1) Offer the school's letterheads so a person can choose one
$templates = app(ListReportTemplates::class)();

// 2) Print your content on it
$pdf = app(RenderReportTemplate::class)($templates[0]->id, [
    ['type' => 'heading',  'text' => 'كشف الدرجات', 'level' => 'h1'],
    ['type' => 'info-row', 'label' => 'الطالب', 'value' => 'أحمد صالح'],
    ['type' => 'table',
     'headers' => ['المادة', 'الدرجة'],
     'rows'    => [['الرياضيات', '95'], ['العلوم', '88']]],
    ['type' => 'divider'],
]);

// $pdf = ['filename' => …, 'mime' => 'application/pdf', 'bytes' => '%PDF…']

Block types

heading (level: h1/h2/h3, text) · text (html) · info-row (label, value) · table (headers, rows) · divider · spacer · image

An unknown type is refused, not skipped. Silently dropping a block produces a document short by one section that says nothing about it, and a report missing a row is worse than a report that failed to print.

Scope

reports.builder.read — nothing here writes. Printing on somebody's letterhead is a different act from redesigning it, and no app does the second.

Bytes come back, not a URL

A stored file needs a lifetime, a cleanup policy and an access rule, for a document that may carry a named child's marks. Handing back the bytes leaves all three with the app that asked.


FAQ

Can I change integration type after creating?

No. Create a new app and archive the old one.

How long is review?

Typically 1-3 business days. Embedded apps take a bit longer.

Can one token serve multiple tenants?

No. Each installation token is bound to exactly one (tenant, app) pair.

The one exception is not a token but a person: your app, open in a complex or company, reads the schools beneath it with ?entities= — see the next question.

Can a complex's app read its schools' data from the mini-app?

Yes, with no server endpoint. Name the schools on the same data read: Okta.getQuery('/api/apps/education/students', {'entities': 'A,B'}) returns an envelope keyed by school, and {'entity': 'A'} returns one school's answer as is. Your app must hold entity.hierarchy.read, the data scope must be granted on the complex or on the school (either is enough), and the person must hold tenants.children.view_members in the complex. A school that never installed your app is read with the complex's grant. Each school's key is either its answer or a refusal carrying error — tell them apart with identical(answer['error'], null), never == null, which misreads the key an answer does not carry. The details are in the chapter «Companies and complexes: the tree and its schools' data», and the example in the native mode chapter.

Can a complex install my app in its schools so they cannot remove it?

Yes — when you opt the app in with {"hierarchy": {"managed_install": true}}. The complex installs it for itself, chooses its schools, and each school keeps the app's settings but cannot uninstall it: it reads «installed by » instead, and only the complex removes it. The complex approves every scope the app requests on the school's behalf. The details are in «A complex installs your app in its schools — hierarchy.managed_install».

Is my app installed in a complex's schools automatically when the complex installs it?

When you choose so: {"hierarchy": {"reach": "container_and_children"}} (the «The complex and its schools» choice in the version editor). It is then installed in every entity beneath the complex as a managed copy the moment the complex installs it, and in every entity that joins it later. Without the key — or with "container" — it is installed in the complex alone, which is what every app does today. The details are in «A complex's install covers its schools — hierarchy.reach».

Can a complex's app write a record in the pupil's own school?

Yes — an embedded app, in process: ActForStudentsInTree for a batch of pupils, ActInChildEntity for one school; there is no HTTP route for it. The person holds two keys at the complex — tenants.children.operate_apps and your app's permission for the act — and only schools whose copy of your app the complex's own installation manages (hierarchy.managed_install / hierarchy.reach) open. The platform places each pupil in their school on the server, runs your code inside that school under the school's own copy, and the parents' notification goes out under that copy — with the school's settings, or the complex's until somebody at the school sets that notification. Never take the school from the request, and never use RunAsChildEntity on a request path. The details are in «Your app works inside a complex's schools — ActInChildEntity».

What if a webhook is delayed?

Nothing is lost. Every delivery shows up on the "Webhook Deliveries" page with manual replay.

Where do I add my app's notifications?

See the Notifications catalog section. Short: Apps → pick your app → "Notifications" tab (/dashboard/modules/<slug>?tab=notifications).


More resources


↧ Download this page as Markdown · API reference →