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
- Integration types
- Quick start
- Scopes
- Platform accounts: who opens your app
- Companies and complexes: the tree and its schools' data
- App lifecycle
- Embedded apps
- Your app's database (migrations)
- Extending the student profile
- Landing widgets
- External apps
- Notification apps
- Payment apps
- Browser tools
- Manifest
- API
- Webhooks
- Notifications catalog
- Security
- Local testing
- AI-assisted development (MCP)
- Design system & UI
- AI support
- Okta mobile app
- Instant messages (realtime)
- Audible announcements (audio & speech)
- Voice notes and file uploads
- Okta's identity inside a mini-app (Liquid Glass)
- Example: an attendance-by-scan app (native)
- Example: Daycare Operations app (External)
- 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— OktaPOSTs to your endpoint with HMAC.embedded— you ship a class inside okta-web that implementsPartnerNotificationProvider.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_urland 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:
- lowercase + dot-separated with no wildcards.
- Partners only ever get
readorwrite. Delete is never granted to partners. - The catalog is the single source of truth. You can't invent new scopes.
- 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
reasona 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:
modelis required and has no default.AdaFace1andArcFace_50r1produce 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.countis nottotal.countis what this page carries after released students and undecryptable rows drop out;totalis what the tenant holds. Page ontotal— deriving it fromcountstops 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.readas 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\ValidateCoreReferenceWritesdoes 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_referencesblock 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
.phpfile living in your repo) carry no SQL that can be inspected at check time; they are counted asunreadableand 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.expiresAtis when the delegation ends (nullfor one that does not) — show it: "ends Thursday" explains the button's disappearance the day after.mayForChildis 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 answersfalse, 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>withfeature= your slug in snake_case (okta-hdor→okta_hdor) — the same rulerbac_permissionscarries, 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.aris required — it is the whole of what a parent reads beside the checkbox, and an unlabelled box is one nobody can tick honestly.label.enfalls 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) ordependent.- Exactly one target:
roles(roles inside the entity) orportal. - At least one surface:
web_routeand/ormobile_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 inrbac_permissionstoo — 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[]orportal(a portal account holds no permission in the entity at all), and it needsweb_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_entryon 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
rolesandpermissions. 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 byset_account_typesbecause one type is now two rows. Withset_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) andversions[]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_adminoutranks the rest: whoever runs the entity stays that, whatever else they hold.employeeis 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 beadministrator.<slug>, which collided with the ACCOUNT TYPEadministrator— 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 andisGroupRole()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":
- Declare your permissions in
rbac_permissionsand give the base role the least it needs; what you want in the school's hands, do not grant to a base role:
The school opens its roles editor and puts"rbac_permissions": { "administrator": ["okta_hr.profile.view"], "tenant-admin": ["okta_hr.profile.view", "okta_hr.management.view"] }okta_hr.management.viewinto a group called "Accounting" — or "Finance" — under the title you wrote inrbac_labels. - Check the permission, not the role:
The accountant — an$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'); }administratortoo — 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. - Never compare
$viewer->groups[*]['key'],['title']or$viewer->rolesagainst 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
permissionsandpermissionSourcescome back empty for somebody who plainly holds them — especially ifauth()->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 forphp 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.hrand "HR manager" are one school's words. An app that writesif ($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.viewinto "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;
employeearrives 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. UseactiveCan()to decide what is shown and keepcan()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. nullmeans «no door», not «no role». Where no door is recorded on the request both fields arenull, andactiveCan()falls back tocan()— so a screen built on it does not go blank. Do not write?? falsearound it, and never readnullas 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
activeRolewasnullon mobile and that «mobile stays on the union». The role was being verified and then discarded; it now arrives, so the one surface with nocan()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.routeis 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_permissionsfor 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 amenu.routeno 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
logandenforce.
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 holdsentity.hierarchy.read, the data scope is granted on the complex or on the school — either is enough — and the person using the app holdstenants.children.view_membersin the complex. The details are in «Reading the schools' data from a complex» below, and the full Dart example in thenativemode 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).nullwhen there is no logo. country.codeis the code/countriesanswers with, andstageis the very object/countries/education-levelslists — 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
.readtravel down. Writing into a school still takes that school's own grant:ActInChildEntityfor a person operating the complex's managed copy (below),RunAsChildEntityin 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 — else403 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:
pageandper_pageapply 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
errorandstatus(andscopewhen a scope is missing), the rest arrives, and the response is200. entity_not_foundfor 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 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 meansmanaged_install: truewhether 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": falsebeside it is a contradictionvalidate_manifestand 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 throughActInChildEntity(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_appsand 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 globaltenant-adminrole 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 yourrbac_permissionsor 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:
- Your app holds
entity.hierarchy.read— elseMissingScopeException. - 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). - Not already inside an act — else
already_acting: a school never acts on what is beneath it with the complex's authority. - The permission is your app's — else
InvalidArgumentException('app_permission_not_declared'); an empty or non-string entry in$requiredChildScopesisInvalidArgumentException('required_scope_invalid'). Bugs in your code, not answers about the person. - That entity is a container — else
not_container— and the ULID names a live entity beneath it — elsechild_entity_not_found, the same answer for a sibling, a parent, a stranger and a ULID that names nothing. - 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). - The school's copy is managed by the complex's installation — else
child_not_managed. - The school's own grant holds every scope in
$requiredChildScopes— elseschool_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) andentityOf($studentUlid).- Thrown rather than recorded per school:
MissingScopeException,not_the_app_root,already_actingand theInvalidArgumentExceptions — 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.
ListOperableChildEntitiesnever throws a refusal:refusalismissing_scope,not_the_app_rootor 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.reador the read's own — isMissingScopeException. Every other refusal isApp\Exceptions\ChildEntitiesNotOperableExceptionwith areason—not_the_app_root,already_acting,not_container, the actor words (no_actor,narrowed,missing_permission,device_elsewhere), andtree_too_largeon the two rosters — rendered to a JSON or API caller as403 {"error": "children_not_operable", "reason": "…"}. - The ceiling refuses; it never trims.
ListOperableStudentsand the card roster refuse a tree of more than 10,000 live pupils or 50 schools astree_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.
GetOperableStudentandGetOperableStudentPhotothrow the sameModelNotFoundExceptionfor 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
codeskey, 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.
ListOperableStudentslists released pupils, as a school's own listing does; the card roster leaves them out.key(onOperableEntityDto, and onManagingEntityDtobelow) is the platform's number for the entity — the valueGetActiveEntityId()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,
DispatchNotificationsends 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, channelall, statusfailed, errorrecipient_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, throughActInChildEntityorActForStudentsInTree.
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 isnarrowed— but no platform surface opens your app for an operator panel yet. Today the one acting is a person. RunAsChildEntityis 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 withActInChildEntity.- 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 nooperateskey at all. Only the Okta app reads it: it is not inOkta.context()and never reaches your server. What you see of it ispackage:okta_faceloading the complex's operated tree instead of one school (chapter «nativemode», 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_installorhierarchy.reach(so there are managed copies),entity.hierarchy.read, and your permission inrbac_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(behindentity.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…"]
}
}
schoolsare the schools' ULIDs as/entity/childrengives them, so join on the id.viewerisnullwhen no person is behind the request (a server).viewer.reachisnullwhen 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_departmentis the department a complex administrator entered as, ornull.- 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
- Read the schools from the platform every session, not from a stored
copy:
/entity/childrengives exactly what this person reaches. - Group the schools under their departments on the head-office screen:
read
/entity/departments, and show «no department» for schools none holds. - Say why the list is short:
viewer.reachnotnull⇒ «showing your department's schools»; empty ⇒ «no schools in your department yet, ask the complex administrator» — never a silent empty screen. - 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.
- 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 thanApp\Services\PartnerApi\* - ✗ Reading platform env keys (DB_, REDIS_, etc.)
- ✗ Reading
.envdirectly
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.phpThe boilerplate ships it starting with
000000deliberately: 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:
- 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.
- 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.
- 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');
}
}
keyandcomponentare derived automatically from the class.- Only
title/label+permissionare required; the rest is optional.
Mandatory rules
- Use
studentHashidonly — never a numeric id. - Every contribution declares a
permissionin 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 passsource-label/accent/icondirectly.)- Status is dynamic: pass
statusandlast-synced-atat 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
studentHashidonly:<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_schemadeclaration — 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_widgetswith any otherintegrationTypefails 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
settingsread 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:
- 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. - Never assume a user. On the public page
auth()->user()is alwaysnull. Code that assumes a session breaks there even though it works in the editor preview. - Developer settings work:
GetAppSetting/GetAppSettingsrun 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. - 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. - 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:
- honeypot — a hidden field the platform owns (see the partial below). Filling it = a bot.
- throttle — per (IP × tenant × module × widget × action); the
default is 5/minute, or
maxPerMinuteper call.throttle()is also available to you standalone, to protect an expensive read. - 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_DOMAINunset: every tenant domain (custom or subdomain) gets its own session. This is the arrangement that works.SESSION_DOMAINpinned to the platform domain: a tenant serving its page fromexample.comgets 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
- Publish: you add
landing_widgetsto your version's manifest and publish it. Validation happens on acceptance (shapes, limits,integrationType=embedded,public_writesmarkings against the live catalog) — a non-conforming block refuses the version. - Install: the tenant installs your app from the store and grants scopes as usual.
- Appearing in the editor: the tenant's widget catalog is derived per request from its installations — installed apps' widgets only.
- 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. - 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_ttlseconds (the cache key carries a settings fingerprint — editing the settings and republishing shows immediately); an interactive widget renders live on every request. - 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.)
- 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 itskeyorphans 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=embeddedand thelanding_widgetsblock ≤ 10 widgets. - Every
keymatches^[a-z][a-z0-9_]{1,39}$, unique within the app — and its name is final. -
livewire_componentin the flat form (no::), registered withLivewire::component()inboot(). -
scopesis 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 everysettingsread behind?? $default. - A static widget has no
wire:*in its blade. - Interactive: extends
PublicWidgetComponent, underapp/LandingWidgets/, and every write insidewithPublicWrite(). - Interactive:
@include('landing-builder::blocks.partner-widget.partials.form-guard')once inside the root element. - All
public_writesare markedpublic_writablein the catalog andinteraction=interactive. - Interactive tested on a custom tenant domain if your tenants use them.
- The render tested with the poor context (no user,
scopesonly), in both page locales, and with an empty data state. -
preview.thumbnail_urlis 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.channelsis a non-empty array of known values:whatsapp,sms,push,slack,email,telegram,voice.notification.deliverymust beapi,embedded, orhybrid.- If
deliveryisapiorhybrid⇒notification.api.send_endpointis required and must be HTTPS. - If
deliveryisembeddedorhybrid⇒notification.embedded.provider_classis required (must be a valid fully-qualified PHP class name likeModules\Foo\Providers\Bar). - A
notificationblock is rejected unlessintegrationTypeis set tonotification.
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;bodyis 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), ornull, in which case you decide: one you map fromnotification_key, or your default.test: trueappears 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, andtemplateIdis alwaysnull.
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 }
}
]
}
]
}
-
keyis what will reach you inmetadata.notification_key, andvariables(name → type) are exactly the names that arrive invariableson every send — your template's slots are built on them. -
tenantis the tenant's state for the key when it has configured it (nullwhen it has not yet): whether it is enabled, on which channels, and which of your templates it chose.default_channelsis the developer's ceiling;tenant.channelsis what the tenant narrowed it to. -
?channel=whatsappnarrows 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_appslists every active install on the tenant with its notification count (your own provider included, at zero) — so whenappscomes 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), andtenant_idin 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/rotateissues 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.deliverymust beapi,embeddedorhybrid.delivery=api/hybrid⇒payment.api.charge_endpointmust be HTTPS.delivery=embedded/hybrid⇒payment.embedded.provider_classmust be a valid FQCN.- A
paymentblock is rejected unlessintegrationType=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) andlabel_en(English): required, 2–60 chars each.kind: required, one ofcard|wallet|bnpl|transfer|cash|other.- Every
custom_methodskey MUST appear inpayment_methods(and vice versa: any non-standardpayment_methodsentry must have a matchingcustom_methodsentry). The create wizard appends custom keys intopayment_methodsautomatically — you never tick them by hand. custom_methodsis 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-Keyheader so a replayed request never double-charges.
Expected response (2xx):
{
"status": "pending",
"provider_ref": "tabby_pay_123",
"redirect_url": "https://checkout.tabby.ai/..."
}
statusis one ofpending|paid|failed.redirect_urlis 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
- 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. - Your embedded app requests a charge through the uniform
CreateChargecontract without knowing which gateway the tenant actually installed. - 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). - Your app follows up on the result in two complementary ways:
- Immediately, from the response itself
(
status: paid|pending|failed). - Later, via the
ChargeUpdatedevent the platform fires on every status transition — needed forpendingcharges that later becomepaidthrough BNPL gateways once the customer completes checkout.
- Immediately, from the response itself
(
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.updateis not in the consumer picker — it is exclusive to payment providers themselves (auto-granted only forintegrationType=payment, to update the status of the charges they execute). Calling it from a consumer context is rejected withpayment_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
pendingcreation (that is not a "transition"), only forpending→paid|failedor a laterpaid→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 onpending/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
- Declare the panel in the manifest via a
developer_uiblock. - 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.
- 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>. - 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
urlinstead (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
keyrule 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 aslivewire_componentis. - A half-valid block never ships: a section missing or malforming
key,labelorlivewire_componentis dropped whole — a nav entry that routes nowhere is worse than a missing one. A duplicatekeykeeps 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:
- 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.
<x-…>components only. No raw<button>/<input>/<select>/<textarea>/<table>— the visual identity lives inside the components.- No fixed colours such as
gray/zinc/slate/indigo; use the platform tokens (neutral,primary,success,warning,danger). - No
max-w-*. The shell enforces the width; adding it breaks the grid and clips long content. - The section's name comes from
label/label_en, not from the blade — the platform draws the title in the navigation. - 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, nogray/zinc/slate/indigo, nomax-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 underDeveloperPanel\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:
- From your own panel component (namespace allowed only in the panel
context):
App\Services\PartnerApi\DeveloperPanel\Settings\{ListSettings, PutSetting, DeleteSetting}— each takes your app'smoduleSlug. - 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.
- From your own panel component (namespace allowed only in the panel
context):
- 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
scriptfield on the version, and Okta serves it from its own origin, to the extension alone.
Three things follow that were not possible before:
- What a school runs is what was published and reviewed. It cannot change under them between reviews.
- A code update reaches everyone with no reinstall of anything. Publish a version; the extension picks it up.
- 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
urlending in.user.jswith noscriptis 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:
delivery: userscriptwith noscript. The body travels inside the version, so a tool without one is not a tool. (Sole exception: the legacy shape with a.user.jsurl.)scriptandurlon the same row. One source of truth per tool — otherwise there is no telling which body a tenant is running.delivery: webstorewith ascriptbody. Nothing runs it: the Web Store extension is the artifact, so the body here is dead code that reads as live.delivery: userscriptwith nomatchpattern. It is the tenant's only disclosure of which sites your tool touches, and what the extension requests permission for.delivery: webstorewith a url ending in.user.js. A mistyped delivery.
Why we block you instead of accepting and fixing:
BuildBrowserToolsBlockdrops 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 meanshttp|httpsonly — not "any scheme".*.example.comincludesexample.comitself, not only its subdomains.- The path is mandatory:
https://noor.moe.gov.sawith 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 thefeatureis 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), notmanage. rbac_labelsis 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 calledokta_hdorcontainingokta_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-adminnever 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:
- NotificationScanner in CI: scans the codebase for any
DispatchNotification('<key>', ...)call and fails the PR when<key>isn't inmanifest.json["notifications"]. - Runtime guard on okta-web: a key the ingested catalog does not
declare is refused by name — a validation exception from
DispatchNotificationinside the module,422with{errors: {key: [...]}}onPOST /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 asskipped_disabledin 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(noid) → 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
audienceis 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
in_appis declared indefault_channelsfor that key — a tenant can narrow your channels, never widen them.- 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 → warningnotification-helper-undefined.- Guardian/student key dispatched with a literal payload and no
recipient→ warningnotification-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_channelsis 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_appandpushfor anything a parent reads in the app,whatsapp/smsfor 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=criticalsparingly — it breaks quiet-hours. - Keep
variables_schemasmall and meaningful. - Use
is_active=falseinstead 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 throughBootModuleContext— 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(scopenotifications.logs.read) withchannel/status/key/from/tofilters. - 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—readorwrite. Not two rungs of a ladder: offeringreaddoes not offerwrite, and the reverse is equally untrue. Declare two rows if you mean both.labelanddescription— 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_v2cannot.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 underModules\in your own code and implement the interface — see below.writer— the counterpart ofreaderon anaccess: writeoffer: 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. $filtersis 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
providesand appear on the consent screen, but have no read channel yet.
Only what is published is visible. Other apps see
providesfrom 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
trueonly 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.
writebeside a livereadis 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 the sandbox: your own apps read each other without consent
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
- The caller is the active module context, not something you sent.
- 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. - 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.ReadFromAppchecks the same thing and then returns the data.
Limits worth knowing
writeis enforced. okta-web compares theaccesscolumn: areadconsent opens reads only, awriteconsent opens writes only. An app that relied on a read row passing a write check (which this guide used to describe) now needs awriteoffer with a writer on the provider, awriteentry 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:
- It arrives as a draft and does not print. A template you just authored
is absent from
GET /reports/templatesandPOST .../renderanswers422for 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. - Only what you authored. The school's own letterhead, and one authored
by another app on the same school, both answer
422on update or delete. (Tenant isolation says nothing here — both apps are legitimately installed.) - 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.
- 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.
- ✓
BlocksPartnerDirectAccesstrait refuses access outsideApp\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_secretin 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.
2) Hosted server — from your account (consent, no tokens)
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 akey, atarget(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:
- 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. - 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.
- RTL-first:
start/end,ms-/me-,ps-/pe-,text-start/text-end. Mirror arrows viartl:rotate-180. IDs/ URLs alwaysfont-mono+dir="ltr". - Card-based composition: each section in
<x-card>. Card paddingp-4mobile /p-5 md:p-6hero. - Single sticky save bar at the bottom for long forms.
- Explicit states: empty / loading / error are first-class.
- Modals via wire-elements/modal exclusively. Open with
wire:click="$dispatch('openModal', { component: '<slug>', arguments: {...} })". Close with$this->closeModal()or$dispatch('closeModal'). - Toasts via
$this->dispatch('toast', message: ...). - Animations come from the
motion-*layer, not your own keyframes —prefers-reduced-motionis 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_catalogMCP 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>, thegray|zinc|slate|indigoneutrals, and container-scalemax-w-*(3xland 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 withform="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.seriestakes 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}].nullis a gap on the line, not a zero.format:number·currency(+currency="SAR") ·percent, withprecisionfor decimals. Numbers are formatted in the page's language.- Colours come from the platform tokens: your app's colour (from
brandin 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.:colorsoverrides the palette when you must. - Live updates with no code: pass
:seriesfrom a property or method on your Livewire component; every re-render updates the chart in place with an animated transition. No event, noentangle. - Empty is handled: all-
nullvalues (or an all-zero pie) show an empty state (emptyTextchanges the copy). Zero on a line is a value, not an absence. :optionsis the escape hatch: any raw ApexCharts option, merged last — reach for it before writingnew 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): eitherown(the partner's own tooling) orplatform(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 centralAiManagerin 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: checkaiSupport/aiModein 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=platformonly. Apps usingaiMode=owncall 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 — theEnsureAiPlatformApprovedmiddleware will return a standard "platform approval pending" response to the user, along with theX-Ai-Approval-Required: trueheader. 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=platformdeclared in your manifest and recorded on the platform (by publishing, or by pushing to sandbox) — see "Howplatformactually 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
- The user types: "Add 5 committees for the first period with 30 students each".
- The component POSTs to
POST /api/apps/ai/agent/stream. - The platform resolves the current app from
AppContextManager, auto-discovers tools underModules/Exams/AiTools/, filters them byrequiredScopes()against the installation's granted scopes, and exposes the survivors to the model. - The model responds with
{"tool": "exams.committees.add", "args": {...}}. - The platform executes
AddCommitteesTool::handle()and returns the result. - 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_installchannel.
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
- Tool name: use the pattern
module.resource.action. - Description: write it like API documentation — the model relies on it to decide when to call.
- JSON Schema: be strict (enums, min/max). It's the only contract between the LLM and your code.
- Return shape: keep it simple (array/scalar). The model summarizes it back to the user.
- Validate early: throw
AiToolExceptionfor 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
webviewentrymust be a repo-relative path insideokta_app/webview/only — nohttp(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.webviewmiddleware (the mobile surface is fenced to the/appnamespace). - In
externalmode you ship nothing here — you host the page, and isolation comes fromallowed 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
rolefield of the external POST) isstudentorguardianwhere 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_dashboardwrites the block straight onto an editable version and runs the publish checks immediately;get_mobile_surfacereads 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.
entryis a package of its own. It lives underokta_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_contractis 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; keepmode: "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_ttlis clamped to 30..3600 at both ends — the published manifest shows the number that will actually be used, not the one you wrote.providermust live underModules\. The platform constructs this class by name, so anything outside your module namespace is a manifest choosing a platform internal to instantiate — and theinstanceofcheck 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)andwatchedFirst(...)— 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
GestureDetectoris never reachable by the remote. A widget becomes a traversal target by owning aFocusNode, and there is no way to give it one withoutFocus. Touch and mouse on Windows still work on it, and a TV remote simply cannot reach it. Wrap it inOktaFocus.item(or put the action on aListTile) — never leave an action on a bareGestureDetectoron the screen surface. - You could not swallow the menu key even if you wanted to, and
OktaFocusdid 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 noKeyEventobject 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
OktaFocustiles — 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 classroomsection_idandsection_name, and an emptyrole_idbecause 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/appscalls in the simulator go through the sandbox seat (an admin account), not a screen device token, soeducation/studentsreturns 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 bysection_idyourself if you want to see what the classroom will see. - From MCP:
simulator_start {slug, surface: "screen", screen_scope: "section"}(or"tenant"), thensimulator_screenshot/simulator_ui/simulator_tapas 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-errorswith the paired device's token), with the message, the stack, your app'smodule_slugand 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_errorsfor the latest,app_errorsfor the triage list, andget_errorwith 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_startwithsurface: "screen"andscreen_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_screenwrites the block (a full replace, ornullto remove it) and runs the publish checks early;get_mobile_surfacereads it back and prints a line per place with its entry. - Repo sync (
sync_from_manifest) carries it exactly as it carriesdashboard.
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 throughscanNfc. 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
falsethere — it does not compile at all, with a message naming a file you never wrote (Cannot find static method, orCannot 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 expectOkta.toolKeyReads()to keep producing NFC rows while you are listening — callOkta.toolKeyStart()again afterOktaNfc.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 answersfalsethere, 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'sentity_idor on your server withActForStudentsInTree. And the firstavailability()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.
cameraScanStartused 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 answerfalsefrom 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".
toolKeyFingerprintanswers''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.readscope your app already holds if it usesresolve. 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
minContractequal 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,toastandscan*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_kitwithCannot find static method, naming a file you never wrote. DeclareminContract: 18, or guard withOkta.contract() >= 18and fall back tobottomBar— which has been there since contract 1, and is the platform's shape today.
flutter analyzewill flagOktaandpackage:okta_hostas undefined — that is expected. The library is injected by the runtime at compile time and is not a real pub package. The authoritative gate isflutter 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,
bodyarrives as a raw string and indexing it with a String key fails at runtime. You cannot guard withif (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? BecauseOktaApiResponseis named byOkta.apiitself, and every app callsapi, 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 whyuploadFile,context,locationandpreciseLocationall 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, orerror), 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 carrieserror(missing_scopewithscope, orentity_not_found) and nototal. Test which one you hold withidentical(answer['error'], null):answer['error'] == nullreads the missing key as false, and storing what it returns kills the mini-app. can_read_childrenbefore 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== truereads 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. CallsetStatedirectly. -
Callbacks must be closure literals, not method tear-offs:
onPressed: () => _doThing(), neveronPressed: _doThing("Cannot box Function"). -
Never pass
cond ? null : closureto a callback slot. Pass an unconditional() => …and guard inside the method (if (_busy) return;). -
Index JSON on a
dynamicreceiver — never through aMap-typed one.body['key']compiles and dispatches correctly whilebodyisdynamic. Give that receiver a raw-Mapstatic type — anis Mapguard promotes it, or you annotate a variable, parameter or fieldMap— andoperator[]switches to static resolution. dart_eval 0.8.5 then binds rawMapto 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 JSONdynamicend 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 Listpromotion is fine — onlyMapis affected. -
No nested loops. dart_eval 0.8.5 throws a
RangeErrorat 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/TextButtononly —FilledButtonandOutlinedButtonare not bridged. -
Layout:
Row/Column(+Expanded).WrapandAlignmentDirectionalare not bridged — build grids N-per-row withRow+Expanded, and reach forDirectionality(notAlign+AlignmentDirectional) for RTL. -
Always pass
flex:toExpanded/Flexible. The bridge does not apply the parameter's default, so an omittedflexarrives asnulland is cast toint—type 'Null' is not a subtype of type 'int', thrown while the widget builds. WriteExpanded(flex: 1, child: …). The same holds for any bridged widget whose optional parameter is a non-nullableint. -
BoxDecorationcarries onlycolorandboxShadow.borderRadius,gradient,imageandshapesit 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. SoBoxDecoration(borderRadius: …)paints square corners with no error anywhere, andvalidate.dartcannot catch it because nothing fails. Round withClipRRect(borderRadius: …, clipBehavior: Clip.antiAlias, child: ColoredBox(…)). -
BoxDecoration(border: …)does not compile at all. The parameter is declared, typedBoxBorder— but the bridge never declaresBorderas a subtype of it, and the compiler knows only what the bridge declares. An ordinaryBorder.all(…)fails withCannot 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/Listshapes 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:
- push your code to the version's branch;
- 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
runtimeSignatureand 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 isversions[]— the next section.) - A type that declares none inherits the block default (
mobile.minContract, which the MCP toolset_mobile_surface.min_contractwrites). What the editor exports asmobile.minContractis 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
nativeonly; the editor clears it in any other mode. - One
.dartpath 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 → 1and 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_typestakesmin_contractper row (native rows with an entry only; a value below 1 is refused by name);get_account_typesandget_mobile_surfaceprint each type's floor, the block default and the exported mirror;validate_manifestrefuses a malformedminContractand 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:
- 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.
- Version-bound lines (newest bound first): the first bound the phone's version meets (and, when its contract is known, whose floor it clears).
- 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.capabilitiesin 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 answertrue. 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. DeclareminContractfor 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 —
scanNfcanswering 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
nativeonly. The tab is handed the student's identifier. Underwebvieworexternalthat 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 theOkta.*calls okta-web authorizes one by one. A declaration under any other mode is dropped.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.- The
education.students.readscope. 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. - 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 notnull? Because'$value'on a null produces the four charactersnull— 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:
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 aboutnativeapplies: the supported dart_eval subset, the limits, and the traps below.- 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.
- 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_manifestrefuse 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: 22only 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,
nullwhen empty. A key merely missing from the decoded map is a rawnullin dart_eval —isthrows on it, and evenOktaJson.strthrows. Keep your payload a fixed shape and fill the gaps with an explicitnull. 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, noFuture.delayedre-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".
falseis an ordinary state on a healthy device. It is not a fault, and not something to retry — asking again in two seconds answersfalseagain 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 notOkta.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
minContractfor 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 clicksuccessplays — 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, andstopAudio()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: 0or4xx/5xxand anerror← 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()answersfalse. 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
uploadFilehas 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_kitis the bodies of existing methods with not one signature moving — no new injected symbol, no new contract, and do not raiseminContract. 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:
BackdropFilterandImageFilter.blurare not in the bridge's vocabulary at all — no blur.BoxDecorationcarriescolorandboxShadowonly;gradientis 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, andvalidate.dartdoes 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_glassneedsmin_contract: 16or 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_contractahead 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
OktaTokensin 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 fromClipRRectnotBoxDecoration - No
Border.all, noBoxDecoration(gradient:)— the first does not compile, the second is dropped in silence - No hand-built glass — use
OktaGlass(andmin_contract: 16) - No shadow behind a rounded corner — it draws square
- No
ElevatedButton(style:), noText(textAlign:)— both are dropped in silence - Components from
OktaButton/OktaSurface/OktaList/OktaStates, not hand-built - Icons from
OktaIconsexclusively - 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.dartbefore publishing — it catches silently dropped parameters and the three traps above before a user does.
The MCP server's
component_catalogtool 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
leadingon the first page. Flutter inserts a back button only whenleading == null, soOktaAppBar.buildWithIconsilently removes the only way out.withBacktakes the slot back for the button and moves the icon intoactions.
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 analyzewill complain thatOktaandOktaPaletteare 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_centeras a primary target tenant type when creating your app. daycare_centertenants 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.readonly. If you later need to write, addeducation.students.writein 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
- Create an External app in the portal; declare
daycare_centeras a target tenant type. - Request
education.students.readfrom the scope picker (addwriteonly if needed). - Subscribe to
education.students.created,education.students.updated, andpartner.installation.token_rotated. - Add a
redirect_urlif you want an OAuth-style initial-sync flow. - On install: fetch the full roster via pagination, then rely on webhooks for ongoing changes.
- 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 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
- OpenAPI: https://partners.getokta.io/docs/openapi.json
- Postman: https://partners.getokta.io/docs/postman_collection.json
- Status: getokta.io/status
- Support: partners@getokta.io