Getting started

Okta Partner App Developer Guide

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

Table of contents

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

Integration types

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

Embedded

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

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

External

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

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

Notification (provider)

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

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

Three delivery modes:

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

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

Payment (provider)

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


Quick start

1. Create a partner account

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

2. Create your first app

From Apps → New app:

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

3. Submit for review

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

4. Approved

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


Scopes

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

<feature>.<resource>.<action>

Examples:

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

Rules:

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

Pull the live catalog:

GET /api/partners/permissions/catalog

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

education.face_embeddings.read — face templates

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

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

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

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

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

Four things to know before you integrate:

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

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

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

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

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

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

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

Response:

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

Store timezone, never offset

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

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

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

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

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

Storage stays UTC

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

Where the value comes from

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

Embedded (PHP):

use App\Services\PartnerApi\Tenants\GetTenantClock;

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

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

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

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

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

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

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


Identifiers and core_reference

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

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

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

The annotation — two forms

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

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

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

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

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

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

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

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

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

What happens without an annotation

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

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

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

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

Forbidden (annotated or not):

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

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

Platform accounts: who opens your app

Scopes answer "what data does your app read". This section answers something else entirely: who is sitting in front of the screen. The platform knows three 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"

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.

Anyone the school records as a Teacher is granted the teacher role automatically. 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.

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" }
]
  • kindprimary (the main audience) or dependent.
  • Exactly one target: roles (roles inside the entity) or portal.
  • At least one surface: web_route and/or mobile_entry.

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

The 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;       // role keys — employees only
$viewer->entityId;    // the entity's ULID

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.

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.

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 customlooks like it works today, and stops working entirely the moment that value is flipped to enforce.

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

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

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

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


App lifecycle

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

Embedded apps

Embedded apps live inside Okta but are forbidden from:

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

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

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

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


Your app's database (migrations)

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

The chain

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

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

File shape

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

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

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

The baseline file 000000_create_<app>_database.php

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

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

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

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

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

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

Getting out:

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

Two statuses: draft and published

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

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

Correcting a migration after it has arrived

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

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

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

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

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

Rows whose file is gone

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

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

Inline SQL in the portal

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

MCP tools

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

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


Extending the student profile

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

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

Folder layout (shipped in the boilerplate)

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

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

Declaration: an attribute on the class

namespace Modules\MyApp\app\StudentProfile\Panels;

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

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

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

Mandatory rules

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

The four types (attribute parameters)

1) #[StudentPanel] — a full card

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

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

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

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

3) #[StudentAction] — a header button

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

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

4) Zone

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

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

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

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

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

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

How it shows in the portal

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

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


Landing widgets

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

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

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

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

Declaring widgets in the manifest

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

Fields

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

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

The settings form: settings_schema

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

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

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

Field properties:

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

An example covering all eight types:

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

Two points govern how storage works:

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

Writing the component

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

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

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

The security model (two asymmetric surfaces)

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

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

context = READ ∪ WRITE

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

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

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

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

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

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

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

Failure is a silent void — and cached

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

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

Interactive widgets

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

The base class is mandatory — and CI enforces it

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

Why: /livewire/update remembers nothing

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

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

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

Writes: withPublicWrite() and the three barriers

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

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

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

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

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

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

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

The form-guard partial — a mandatory include

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

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

The deployment trap: SESSION_DOMAIN — know it before you ship

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

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

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

The only public_writable scope today

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

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

Lifecycle

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

Registering the alias and the component

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

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

namespace Modules\MyApp\Livewire\Landing;

use Livewire\Component;

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

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

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

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

Common errors

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

Pre-publish checklist

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

External apps

When you create an External app you provide:

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

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


Notification apps

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

Manifest block

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

Validation rules enforced by the platform:

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

Scopes (granted automatically)

Picking integrationType=notification auto-attaches:

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

Path 1: delivery=api

The platform sends:

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

{
  "channel": "whatsapp",
  "recipient_identifier": "+966500000000",
  "message": {
    "body": "Message body",
    "title": null,
    "template_id": null,
    "variables": {},
    "media": []
  },
  "metadata": { "recipient_type": "phone" }
}

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 {
            $response = SmsClient::send([
                'to'   => $payload->recipientIdentifier,
                'text' => $payload->body,
            ]);
            return NotificationResult::success(providerId: $response['id'] ?? null);
        } catch (\Throwable $e) {
            return NotificationResult::failure($e->getMessage());
        }
    }
    public function supports(string $channel): bool { return $channel === 'sms'; }
    public function isConfigured(): bool { return ! empty(config('acme-sms.api_key')); }
}

Path 3: delivery=hybrid

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

Settings UI (settings_ui) — optional

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

Per-version overrides

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


Payment apps

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

Manifest block

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

Validation rules the platform enforces:

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

Custom payment methods

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

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

Rules:

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

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

Scopes (granted automatically)

Picking integrationType=payment auto-attaches:

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

Path 1: delivery=api

okta-web sends your endpoint a signed charge request:

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

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

Expected response (2xx):

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

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

Path 2: delivery=embedded

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

Path 3: delivery=hybrid

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

Consuming payments from your app (Embedded only)

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

How it works, end to end

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

Required scopes

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

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

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

Services (in-process — the primary path)

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

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

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

Example 1 — selling a feature/service from your app

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

namespace Modules\SchoolPortal\Services\Reports;

use App\Services\PartnerApi\Payments\CreateCharge;

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

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

From the calling Livewire component:

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

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

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

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

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

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

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

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

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

namespace Modules\SchoolPortal\Providers;

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

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

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

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

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

Important notes about the event:

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

Example 3 — reconciliation

use App\Services\PartnerApi\Payments\ListCharges;

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

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

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

Refunds

use App\Services\PartnerApi\Payments\RefundCharge;

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

RefundCharge guards:

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

Per-version overrides

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


App Control Panel

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

How it works

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

Manifest block

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

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

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

Panel sections (sections)

Who owns what — everything below follows from this split:

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

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

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

Panel design policy

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

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

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

The controls

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

Full example

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

namespace Modules\MyApp\app\DeveloperPanel;

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

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

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

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

The blade uses platform components only:

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

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

Available stats

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

App Data

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

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

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

use App\Services\PartnerApi\AppSettings\GetAppSetting;

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

Write from an action inside your panel component:

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

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

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

Browser tools

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

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

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

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

Three things follow that were not possible before:

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

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

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

Declaring one

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

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

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

world: the most dangerous field here

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

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

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

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

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

The cross-checks that drop a row

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

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

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

match patterns

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

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

Three things people get wrong:

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

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

The manifest shape

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

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

Version-level override

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

Know what that means before you do it:

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

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

Updating a published tool

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

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

What a tool cannot do

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


Manifest

Every app emits a manifest.json describing its capabilities:

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

External apps add external block; notification apps add notification block; payment apps add payment block (see above). Embedded apps that extend the student profile add a studentProfile block (see Extending the student profile).

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


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, 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.

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

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


Webhooks

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

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

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

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

Respond 2xx within 10 seconds.


Notifications catalog

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

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

Philosophy: declare-first

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

  1. NotificationScanner in CI: scans the codebase for any DispatchNotification('<key>', ...) call and fails the PR when <key> isn't in manifest.json["notifications"].
  2. Runtime guard on okta-web: if a dispatch arrives for a key not in the ingested catalog, it's silently dropped and logged. Nothing ships to the tenant.

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. Tenants narrow this further.
severity enum info / warning / critical.
is_active bool Toggle without deleting. Inactive notifications are silently dropped at 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.

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

use App\Services\PartnerApi\Notifications\DispatchNotification;

app(DispatchNotification::class)('hr-pro.leave_request.approved', [
    'employee_name' => $request->user()->name,
    'leave_days' => 5,
    'start_date' => '2026-04-26',
]);

Short form:

app(DispatchNotification::class)('<key>', $payload);
partner_notify('<key>', $payload);   // helper

All three are detected by NotificationScanner.

The scanner

scripts/partner-policy/NotificationScanner.php collects every "used" key from 3 patterns and compares against "declared" keys in manifest.json:

  • Used but not declared → blocking violation (fails CI).
  • Declared but not used → warning (non-blocking).

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', [
            '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.
  • Start with conservative default channels (in_app + email).
  • Use severity=critical sparingly — it breaks quiet-hours.
  • Keep variables_schema small and meaningful.
  • Use is_active=false instead of delete on a published version.

Common errors

Error Fix
notification-key-not-declared Declare on the tab, pull manifest.
notification-key-unused (warning) Remove key or wire the dispatch.
cannot_edit_published Create new version, catalog auto-cloned.
delete_blocked_by_installs Use is_active=false instead.
key_prefix validation error Type only the suffix after <slug>..

Local testing

app(\App\Services\PartnerApi\Notifications\DispatchNotification::class)(
    'hr-pro.leave_request.approved',
    ['employee_name' => 'Sara', 'leave_days' => 3]
);

Inspect: notifications table on okta-web sandbox, tenant inbox (Mailtrap), Notification log page.


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.
  • accessread or write. Not two rungs of a ladder: offering read does not offer write, and the reverse is equally untrue. Declare two rows if you mean both.
  • label and descriptionwritten for the school, not for a programmer. This exact text is what appears on the store's consent screen. "Daily attendance records" can be read and decided on; att_rec_v2 cannot.
  • readerthe field that makes the offer yield anything. Everything else is consent paperwork establishing that a link may exist; this class is what the platform actually runs to produce the rows. Without it a school can grant the row and it returns nothing. It must live under Modules\ in your own code and implement the interface — see below.

Write the reader

namespace Modules\Attendance\Services\PartnerApi;

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

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

Three rules that are not negotiable:

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

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

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

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

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.
  • Refusewith 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.

4. Declare the dependency in your version

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

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

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

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

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

What happens at install

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.

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.

Actually reading the data

One call, from inside your app:

use App\Services\PartnerApi\CrossApp\ReadFromApp;

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

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

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

What the call checks before returning anything

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

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

A refusal is an exception, not an empty array

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

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

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

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

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

Limits worth knowing

  • write is not enforced yet. okta-web currently matches on (tenant, requester, provider, resource) and does not compare the access column, so a grant issued as read passes a write check today. The declaration is honest and is shown to the school as written, but do not build a security boundary on that distinction until the gap is closed.
  • 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. The other app must still publish what you name in its provides.

Printing on the school's letterhead (report templates)

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

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

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

1. List the letterheads

GET /api/apps/reports/templates

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

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

2. Print

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

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

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

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

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

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

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

Proposing a letterhead

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

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

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

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

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

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

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

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

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

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

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

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


Security

Embedded

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

External

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

Your responsibilities

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

Local testing

Sandbox tenant

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

Webhook tunneling

For External apps use ngrok:

ngrok http 8000

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

Postman / Insomnia

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


AI-assisted development (MCP)

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

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

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

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

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

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

Connect (one-time):

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

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

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

Key live tools:

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

Account types over MCP

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

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

Each entry: key (must equal the role name or the portal) + kind (primary | dependent) + exactly one target: roles (one role) or portal + at least one surface: web_route (a Laravel route name such as school-app.admin — not a URL and not a /path) and/or mobile_entry (in the shape the version's mobile mode requires). This single declaration is what builds menu.audiences[] in okta-web and mobile.audiences[] in the Okta app, and the tool refuses up front whatever the publish-time validator would refuse.

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

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


Design system & UI

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

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

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

Room to design — what is actually yours

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

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

1. The card is flexible now

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

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

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

2. Your own colour

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

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

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

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

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

3. Motion

A ready layer instead of writing @keyframes:

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

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

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

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

4. The gate has two channels, not one

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

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

5. Modals

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

1. The component extends ModalComponent, not Component:

use LivewireUI\Modal\ModalComponent;

class WithdrawalRequestModal extends ModalComponent
{
    public int $moduleId;

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

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

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

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

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

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

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

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

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

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

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

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

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

Reference files in the platform

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

AI support

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

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

Where to declare it in the platform

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

The options

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

How platform actually gets approved

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

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

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

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

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

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

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

Reading the 403 when it comes

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

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

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

Detailed reference

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

AiManager usage examples

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

Injecting AiManager

use App\AI\AiManager;
use App\AI\AiException;

class StudentReportsController extends Controller
{
    public function __construct(private AiManager $ai) {}

    public function summary(Request $request)
    {
        try {
            $summary = $this->ai->summarize($request->long_text);
            return response()->json(['summary' => $summary]);
        } catch (AiException $e) {
            return response()->json(['error' => $e->getMessage()], 502);
        }
    }
}

Laravel resolves AiManager automatically through AiServiceProvider (registered as a singleton). You can also use app(AiManager::class) or app('ai').

chat() — simple conversation

$reply = app(AiManager::class)->chat(
    prompt: 'Summarize the following student report in 3 bullet points',
    context: [
        ['role' => 'user',      'content' => 'Student report: ...'],
        ['role' => 'assistant', 'content' => 'Sure, let me read the report.'],
    ],
    opts: [
        'model'         => 'gpt-4o',     // optional — default picked by Okta's engine
        'temperature'   => 0.3,
        'max_tokens'    => 500,
        'system_prompt' => 'You are a professional educational assistant.',
    ],
);

stream() — streaming response (for interactive UIs)

use App\AI\AiManager;

return response()->stream(function () {
    $full = app(AiManager::class)->stream(
        prompt: 'Explain the concept of factor analysis',
        context: [],
        opts: ['temperature' => 0.5],
        onChunk: function (string $chunk) {
            echo "data: " . json_encode(['text' => $chunk]) . "\n\n";
            ob_flush();
            flush();
        },
    );
}, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);

In Livewire, use wire:stream or update a public property from inside the onChunk callback.

complete() — text completion (no chat history)

$completion = app(AiManager::class)->complete(
    text: "In his essay, the student wrote: 'Education is the path to",
    opts: ['max_tokens' => 50],
);
// result: "...progress and renaissance in modern societies, and therefore..."

summarize() — summarize a long text

$summary = app(AiManager::class)->summarize(
    longText: $student->report_full_text,
    opts: [
        'max_tokens' => 300,
        'system_prompt' => 'Summarize in one paragraph, focusing on strengths and weaknesses.',
    ],
);

translate() — translation

$ar = app(AiManager::class)->translate(
    text: 'The student excels in mathematics and sciences',
    toLocale: 'ar',
);
// result: "الطالب متفوّق في الرياضيات والعلوم"

Error handling

use App\AI\AiException;
use Illuminate\Http\Client\ConnectionException;

try {
    $result = app(AiManager::class)->chat($prompt);
} catch (AiException $e) {
    // Provider failure, quota exceeded, model unavailable, ...
    Log::warning('AI request failed', ['error' => $e->getMessage()]);
    return back()->with('error', 'Could not process your request right now. Try again later.');
} catch (ConnectionException $e) {
    // Service unreachable (rare)
    return back()->with('error', 'AI service is unavailable.');
}

Do not catch PlatformAiNotApprovedException (HTTP 403). Let it propagate — the EnsureAiPlatformApproved middleware will return a standard "platform approval pending" response to the user, along with the X-Ai-Approval-Required: true header. This is the platform's canonical approval-required UX, and swallowing the exception breaks it.

aiMode=own examples — partner's own tooling

If you picked aiMode=own, Okta plays no part in routing requests. You call your provider directly using its SDK. OpenAI example:

use OpenAI\Laravel\Facades\OpenAI;

$response = OpenAI::chat()->create([
    'model' => 'gpt-4o',
    'messages' => [
        ['role' => 'system', 'content' => 'You are an educational assistant'],
        ['role' => 'user',   'content' => $userInput],
    ],
]);

$reply = $response->choices[0]->message->content;

Or Anthropic Claude:

$response = Http::withHeaders([
    'x-api-key' => config('services.anthropic.key'),
    'anthropic-version' => '2023-06-01',
])->post('https://api.anthropic.com/v1/messages', [
    'model' => 'claude-opus-4-7',
    'max_tokens' => 1024,
    'messages' => [
        ['role' => 'user', 'content' => $userInput],
    ],
])->json();

$reply = $response['content'][0]['text'];

Your responsibilities in own mode:

  • Store API keys securely (env vars, KMS, ...)
  • Manage usage quotas and provider billing
  • Disclose to the tenant any data sent outside the platform
  • Comply with privacy and data-protection policies

See ai-support.en.md for the full reference on aiSupport/aiMode validation rules and migration paths.

AI Agent with tool use

Apps that want AI that performs actions (not just answers) use the Agent with the concept of "Tools". You define tools as PHP classes, and the AI decides when to call them — for example, when a user asks "add 5 committees with 30 students each", the AI autonomously calls the exams.committees.add tool with the right arguments.

This feature requires aiMode=platform declared in your manifest and recorded on the platform (by publishing, or by pushing to sandbox) — see "How platform actually gets approved" above.

Tool anatomy

Each tool in your app is a PHP class that implements the App\AI\Contracts\AiTool interface and lives under Modules/<ModuleStudly>/AiTools/. The platform auto-discovers them when the app loads — no manual registration required.

Full example: AddCommitteesTool in the Exams app

File: Modules/Exams/AiTools/AddCommitteesTool.php

<?php

namespace Modules\Exams\AiTools;

use App\AI\Contracts\AiTool;
use App\AI\Exceptions\AiToolException;
use Modules\Exams\Models\ExamCommittee;
use Modules\Exams\Services\CommitteeDistributor;

class AddCommitteesTool implements AiTool
{
    public function __construct(
        private readonly CommitteeDistributor $distributor,
    ) {}

    public function name(): string
    {
        return 'exams.committees.add';
    }

    public function description(): string
    {
        return 'Creates new exam committees and distributes students across them '
            .'according to the given settings. Use this tool when the user asks '
            .'to add committees or distribute students into committees.';
    }

    public function parametersSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'committee_count' => [
                    'type' => 'integer',
                    'minimum' => 1,
                    'maximum' => 100,
                    'description' => 'Number of committees to create',
                ],
                'students_per_committee' => [
                    'type' => 'integer',
                    'minimum' => 1,
                    'maximum' => 60,
                    'description' => 'Number of students per committee',
                ],
                'exam_period' => [
                    'type' => 'string',
                    'enum' => ['first', 'second', 'final'],
                    'description' => 'The exam period (first/second/final)',
                ],
                'distribution_strategy' => [
                    'type' => 'string',
                    'enum' => ['alphabetical', 'random', 'by_grade'],
                    'default' => 'alphabetical',
                    'description' => 'The distribution strategy',
                ],
            ],
            'required' => ['committee_count', 'students_per_committee', 'exam_period'],
        ];
    }

    public function requiredScopes(): array
    {
        return ['exams.committees.write', 'education.students.read'];
    }

    public function handle(array $params): mixed
    {
        $count = (int) $params['committee_count'];
        $perCommittee = (int) $params['students_per_committee'];
        $period = (string) $params['exam_period'];
        $strategy = $params['distribution_strategy'] ?? 'alphabetical';

        $available = $this->distributor->countAvailableStudents($period);
        $needed = $count * $perCommittee;

        if ($available < $needed) {
            throw new AiToolException(
                "Available students ({$available}) is fewer than required ({$needed}). "
                ."Reduce the number of committees or students per committee."
            );
        }

        $committees = $this->distributor->createAndDistribute(
            count: $count,
            studentsPerCommittee: $perCommittee,
            period: $period,
            strategy: $strategy,
        );

        return [
            'created' => $committees->count(),
            'total_students_assigned' => $committees->sum('student_count'),
            'period' => $period,
            'committee_ids' => $committees->pluck('id')->all(),
            'message' => "Created {$count} committees and assigned {$needed} students successfully",
        ];
    }
}

Embedding the chat UI in your app's page

<x-ai-agent
    :title="'Committees Assistant'"
    :placeholder="'e.g. Add 5 committees for the first period with 30 students each'"
    :system-prompt="'You are a smart assistant for managing exam committees in the okta-exams app. Help the user create, distribute, and manage exam committees.'"
    height="600px"
/>

How the flow works

  1. The user types: "Add 5 committees for the first period with 30 students each".
  2. The component POSTs to POST /api/apps/ai/agent/stream.
  3. The platform resolves the current app from AppContextManager, auto-discovers tools under Modules/Exams/AiTools/, filters them by requiredScopes() against the installation's granted scopes, and exposes the survivors to the model.
  4. The model responds with {"tool": "exams.committees.add", "args": {...}}.
  5. The platform executes AddCommitteesTool::handle() and returns the result.
  6. The component renders each step live (running → done) via SSE frames (tool_call, tool_result, text, done, error).

Error handling

  • AiToolException: a recoverable error — the message is fed back to the model so it can adjust and retry. Use it for validation failures.
  • Any other throwable: aborts the entire agent turn and logs the failure on the partner_install channel.

Permissions

Every tool declares requiredScopes(). The agent loop filters out tools whose scopes the installation doesn't hold before showing the toolset to the model. This means a write-capable tool stays safe even when the tenant only granted read scopes.

Iteration cap

Each agent turn is bounded to 5 iterations. After the cap, the model is asked to produce a final summary instead of more tool calls.

Authoring tips

  1. Tool name: use the pattern module.resource.action.
  2. Description: write it like API documentation — the model relies on it to decide when to call.
  3. JSON Schema: be strict (enums, min/max). It's the only contract between the LLM and your code.
  4. Return shape: keep it simple (array/scalar). The model summarizes it back to the user.
  5. Validate early: throw AiToolException for expected error paths so the model can retry sensibly.

Testing a tool locally

// tests/Feature/AddCommitteesToolTest.php
$tool = app(\Modules\Exams\AiTools\AddCommitteesTool::class);
$result = $tool->handle([
    'committee_count' => 3,
    'students_per_committee' => 25,
    'exam_period' => 'first',
]);
$this->assertEquals(3, $result['created']);

Okta mobile app

The Okta mobile app shows the user a set of service cards. Your app can expose a service that appears as a card inside it. This is declared per version — a later version can opt in without touching the original app.

Where to enable it

Partner dashboard → app → version → Integration tab → "Services inside the Okta mobile app" card → toggle it on, then provide the settings.

The three modes — pick one per version

Mode What you ship Where Rendered as
native Real Dart (Flutter) okta_app/native/<entry>/lib/ A native UI inside the Okta app
webview Web files okta_app/webview/ A confined WebView
external Nothing — you host it An HTTPS URL of yours A WebView on your URL

native is the only mode that gives you a native UI, and the only one that can reach device hardware through Okta.* calls. The other two go through the browser permission model, which the platform does not mediate.


Settings you provide

Setting Description
mode native (Dart code — a native UI), webview (web files in your repo) or external (a URL you host).
entry native: a .dart file under okta_app/native/<entry>/lib/. webview: repo-relative path inside okta_app/webview/. external: full HTTPS URL.
min_contract native only. The lowest host contract your code needs.
allowed origins External only: HTTPS origins the page may be loaded from.
required scope Optional. The card is shown only if the user's active role holds this scope (from the scopes granted to the version). Empty = shown to any role that can open the app.
pass role claim Optional, external only. Adds the role claim inside the signed JWT. No database access either way.

File structure (webview mode)

Put everything rendered in the mobile app inside a okta_app/webview/ folder at the root of your app repo:

mobile/
├── README.md
├── manifest.json        ← optional metadata for the mobile surface
├── screens/             ← entry files rendered in the WebView
│   └── dashboard.blade.php
└── assets/              ← css / js / images for those screens

Example entry: okta_app/webview/screens/dashboard.blade.php.

Policy (enforced)

Mobile services are confined to the dedicated okta_app/webview/ namespace:

  • The webview entry must be a repo-relative path inside okta_app/webview/ only — no http(s):// scheme, no .. traversal. Anything else is rejected at save/review time.
  • Mobile screens may not link, navigate, or redirect to any platform/tenant page outside the okta_app/webview/ namespace.
  • This is also enforced at runtime by okta-web's app.webview middleware (the mobile surface is fenced to the /app namespace).
  • In external mode you ship nothing here — you host the page, and isolation comes from allowed origins + zero data binding.

Mobile dashboard

okta-app's first screen is a dashboard, not an app list. The launcher has a tab of its own — Apps, in the bottom bar — and the dashboard shows what the installed apps themselves have to say: today's numbers, the queue, what just happened.

Your app can put a block on it. Every mode may — native, webview and external alike — because a block is data, not a surface.

The portals get the same home

The student's and the guardian's portal homes are dashboards too, with the portal launcher on its own Apps tab — the same shape the tenant home has. Your block reaches a portal automatically wherever your app declares an audience for it; nothing new to declare, and audiences narrows there exactly as it does for roles — write ["guardian"] to brief guardians only.

Two things differ, both by design:

  • The audience parameter carries the portal. Your provider's first argument (and the role field of the external POST) is student or guardian where a tenant call passes the role key. Same parameter, same meaning — who is reading — and a provider that ignores it briefs every audience the same figures, which is a fine default.
  • The portal is cross-tenant, so your block appears once per school. A guardian with children at two schools that installed your app gets two blocks — the figures differ, and okta-app labels each with its school's name. You do nothing for this: your provider runs once per school in that school's context, exactly as it always has.

The rule everything else follows: data, not design

You return figures and labels; okta-app draws them, identically for every app. That is not a limitation to work around — it is the reason a dashboard carrying five apps still reads as one screen. A block that brought its own layout would be a partner's card sitting inside our dashboard, and the reader's first question — who is telling me this, and how old is this number? — would get five different answers.

It is also why a block costs nothing: no mini-app compile, no sandbox, no contract version. Five blocks are five array returns, not five interpreters.

Declaring it

Inside mobile in the manifest:

"mobile": {
  "supported": true,
  "mode": "native",
  "entry": "main",
  "dashboard": {
    "enabled": true,
    "title": "حضور اليوم",
    "title_en": "Today's attendance",
    "provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
    "audiences": ["tenant-admin", "teacher"],
    "cache_ttl": 300
  }
}
Key Required Description
enabled Must be true. Do not ship a disabled block — omit the whole thing.
title / title_en (one of) The block's heading. Without it the card is headed by your slug, which tells the person holding the phone nothing.
provider embedded A class under Modules\ implementing MobileDashboardProvider.
endpoint external An https URL that receives one signed POST.
audiences Role keys. Narrows, never widens (see below).
cache_ttl Seconds. Clamped to 30..3600.
portal_scope per_tenant (default) or combined — how the block reads on a portal home when one person meets several schools.
mode Who draws the inside: data (default — the host draws your figures) or native (your Dart draws it — see below).
entry with native The card's Dart entry file under okta_app/native/<package>/lib/.
min_contract with native The okta-app host contract the card needs. Floor 21; lower values are clamped up.

💡 Instead of editing manifest.json by hand: the MCP tool set_mobile_dashboard writes the block straight onto an editable version and runs the publish checks immediately; get_mobile_surface reads it back. The repo sync (sync_from_manifest) carries it too.

The transport follows integrationType and is not declared. An embedded app names a class the platform calls in-process; an external one names a URL the platform signs one request to. Declaring both — or the wrong one — is rejected at publish, because a second switch for something the manifest already answers means the first app to set them inconsistently publishes a block that never appears.

Embedded: implement the contract

namespace Modules\OktaHdor\App\Services;

use App\Services\PartnerApi\Contracts\MobileDashboardProvider;

final class DashboardProvider implements MobileDashboardProvider
{
    public function dashboard(string $roleKey, string $locale): array
    {
        $today = Attendance::query()->whereDate('taken_at', today());

        return [
            'stats' => [
                [
                    'label' => $locale === 'en' ? 'Present' : 'حاضر',
                    'value' => number_format($today->clone()->where('status', 'present')->count()),
                    'tone'  => 'success',
                ],
                [
                    'label' => $locale === 'en' ? 'Absent' : 'غائب',
                    'value' => number_format($today->clone()->where('status', 'absent')->count()),
                    'tone'  => 'danger',
                ],
            ],
            'rows' => [
                ['title' => 'Year 1', 'subtitle' => 'A', 'value' => '98%'],
            ],
            'open_label' => $locale === 'en' ? 'Open register' : 'فتح السجل',
        ];
    }
}

It runs inside your own module context, reading your schema with your credentials exactly as your own pages do. Scope every query to the current tenant.

External: receive a signed POST

POST <endpoint>
X-Okta-Timestamp: 1755859200
X-Okta-Signature:  HMAC-SHA256( "<timestamp>.<body>", install_signing_secret )

{"tenant_id": 42, "role": "tenant-admin", "locale": "en"}

Verify the signature before answering, and answer in the same array shape. The timeout is 6 seconds; past it you are treated as unreachable.

Nothing identifying the person is sent. A figure on the dashboard is about the school, and you do not need to know which member of staff is looking at their phone to count today's absences.

The payload

[
    'title'      => "Today's attendance", // optional — overrides the manifest title
    'stats'      => [ ['label' =>, 'value' =>, 'caption' => ?, 'tone' => ?] ],
    'rows'       => [ ['title' =>, 'subtitle' => ?, 'value' => ?, 'tone' => ?] ],
    'open_label' => 'Open register',      // optional — omit to show no button
    'message'    => null,                 // a note shown with the figures
]

value is a string you have already formatted. Percentages, currencies, ratios and counts share no formatter, you know which you are producing, and a numeric field would force the client to guess — or carry a format vocabulary that grows with every partner.

tone is a meaning, not a colour: neutral | success | warning | danger | info. The host resolves it to a palette that matches the rest of the screen in both light and dark; a hex you pick would match neither.

Limits are enforced: 6 stats, 8 rows, 120 characters per string. The excess is truncated silently — a block that takes the whole screen is not a block.

Keys outside the vocabulary are dropped, never passed through. An unrecognised key that reached the client would become a de-facto part of the contract the first time a partner relied on it.

Narrowing by role: it narrows, never widens

audiences are role keys that restrict the block to some of the people who can already see your app. The dashboard is built from the catalog the user already receives, so an app hidden from a role cannot brief that role through the back door. Leave it empty for every role that can see the app.

Failure is a state, not an exception

Throw when you fail. The platform catches it, logs it, and shows your block offline — or with its previous figures marked stale if any were cached.

Do not return an empty array instead. Empty is indistinguishable from "there is genuinely nothing today", and would show an operator a confident zero on a morning your query was broken.

The platform also keeps a day-long fallback copy to answer with when you cannot: yesterday's attendance marked "not current" is more use to someone standing at a school gate than an empty box.

Drawing the card yourself — mode: "native"

Everything above holds — and then an embedded app can go one step further and draw the inside of its card in real Dart, through the same source-on-device runtime its mini-app already uses:

"dashboard": {
  "enabled": true,
  "title": "حضور اليوم",
  "title_en": "Today's attendance",
  "provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
  "mode": "native",
  "entry": "okta_app/native/card/lib/main.dart",
  "min_contract": 21
}

The host keeps the frame. The corners, the header with your app's name and icon, the sync chip and the "open" button stay the platform's; your widget owns the inside of a box whose edges it cannot reach — including its height: the card is clipped at 320pt, not scrolled, because a scrollable inside a dashboard that itself scrolls steals the drag from the reader.

provider stays required, and that is the design, not a leftover. The figures it returns are the card an older phone draws — and the card this phone draws the moment anything goes wrong. Every failure lands there, silently, in the same session: source that will not compile, a widget that throws while building, a first frame that does not arrive within 500ms of the runtime handing over, a device whose host contract is below min_contract. There is no partner-visible error state on the home screen — the reader gets your data card, and the home screen never breaks because of a partner. Test your card by making it throw on purpose: what you should see is your own figures.

  • entry is a package of its own. It lives under okta_app/native/<package>/lib/ like every native entry, and should be a dedicated package (okta_app/native/card/) rather than a second file inside your app's package — delivery slices one package per entry, so a card entry inside the app's package would download and compile your whole app to draw one card.
  • min_contract is required and 21 is the floor — 21 is where the card runtime shipped, so lower values are clamped up. A device below it never mounts the card and shows the data card instead; that refusal is deliberate and invisible, not an error.
  • The sandbox is the mini-app's sandbox. Same Okta.* API, same isolation, same theme. One symbol exists for cards specifically: Okta.openMiniApp() opens your full app — the same act as the host's own "open" button. Deep navigation stays in the app; a card is a summary, not a screen.
  • Portals multiply the card, not its state. A guardian with children at two schools gets two cards, compiled and mounted separately, sharing no state, no tenant and no delegate — the same per-school isolation the full mini-app has.
  • Embedded only. A native card compiles from your signed source bundle, and an external app ships no code — mode: "native" is refused at publish; keep mode: "data" with your endpoint.

set_mobile_dashboard writes all three keys and runs these checks at dev time; the manifest schema and the publish gate repeat them.

Before you publish

  • Your block appears only to people who can already see your app in the catalog.
  • cache_ttl is clamped to 30..3600 at both ends — the published manifest shows the number that will actually be used, not the one you wrote.
  • provider must live under Modules\. The platform constructs this class by name, so anything outside your module namespace is a manifest choosing a platform internal to instantiate — and the instanceof check is too late, because the constructor has already run.
  • Nothing in a block is tappable except one button that opens your app. No links, no HTML, no images.

Display screens (Okta Screen)

A phone is opened by a person; a screen is opened by nobody. A television on the classroom wall, a panel in the school lobby, a display by the complex's gate — a machine that runs all day with no user in front of it, driven by a separate app called Okta Screen (Android TV / Google TV, and Windows). It signs in by pairing only: no account and no password, just a code the organisation's administrator mints from the device registry and types on the screen once.

What sets a screen apart from a phone is where it stands, not who holds it: at pairing the administrator picks its place — the whole organisation (a lobby, a corridor) or one classroom. Your app declares which of the two places it serves, and the platform offers it to the screens standing there.

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.
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.

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 three 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' or 'tenant'
final dynamic sectionId = ctx['section_id'];     // the classroom ULID, or null on a tenant screen
final dynamic sectionName = ctx['section_name']; // e.g. "1-A"

['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.

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 classroom screen sees its classroom only — enforced by the server

This is not advice for your code. A screen paired to a classroom reaches student data through /api/apps/education/students pinned to its classroom on the server:

  • no filter at all → the pupils of this classroom, not the school;
  • ?section_id= naming another classroom → an empty page;
  • /students/{ulid} for a pupil of another classroom → 404, as if it did not exist;
  • /education/sections → this one classroom.

So a roll-call app on a classroom screen cannot list the school even if it asks. A tenant screen sees what your phone app sees, under the same scopes the installation was granted — there are no screen-specific scopes.

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 is a self-scheduling Future.delayed recursion with a stop flag you set in dispose — and a long interval: every turn is a round trip to the host. Run flutter test tool/validate.dart inside every screen package you ship: the gate compiles one package, so a green classroom screen says nothing about the lobby.

D-pad support and the menu key

No touch, ever, on a screen: control is a TV remote (direction keys + OK) or a keyboard on Windows (arrows + Enter). What follows was measured in this sandbox, not inferred from Flutter in general — the difference between the two matters a great deal here.

Stock widgets support the remote automatically, with no extra line of code. ElevatedButton, TextButton and ListTile: arrows and Tab move focus onto them, and OK/Enter (and the gamepad A button) fires the onPressed/onTap inside your on-device-compiled code. That is all d-pad support takes — use those widgets for anything the remote is meant to reach.

And a card you drew yourself is reachable too — since contract 25 (package:okta_focus). This is the new part; before it, no trick made it possible:

import 'package:okta_focus/okta_focus.dart';

OktaFocus.first(               // ← holds focus on the first frame
  myBigCard(student),          // ← anything you drew: Container, Row, image…
  () => _call(student),        // ← fires on OK/Enter, and on tap
)
  • OktaFocus.item(child, onSelect) — the ordinary one, and the one to reach for.
  • OktaFocus.first(child, onSelect) — the same, and it holds focus on the first frame. One per screen: two widgets asking for the first focus is two answers to a question with one, and which wins is not something to build on.
  • OktaFocus.watched(child, onSelect, onFocusChange) and watchedFirst(...) — tell you when focus enters and leaves, so you can enlarge or tint your own tile.
  • OktaFocus.reachable(child) — the arrows reach it, nothing is pressed: for a row in a long list the ring should travel through rather than skipping a block of content.

The host draws the focus ring, in the shell's colour, with its width always laid out so gaining focus never nudges the row your tile sits in. You do not draw it and cannot hide it: a board on a wall with no visible ring is a board nobody in the room can navigate. Use watched to add your own emphasis on top of it, never instead of it.

autofocus: true on a stock button still does nothing, because the bridged button has no such parameter to arrive through. OktaFocus.first is the answer.

Focus, FocusNode, FocusTraversalGroup, KeyboardListener and PopScope do not exist here at all — each fails with a compile error (Could not find declaration), not a runtime one. Two things follow:

  • A bare GestureDetector is never reachable by the remote. A widget becomes a traversal target by owning a FocusNode, and there is no way to give it one without Focus. Touch and mouse on Windows still work on it, and a TV remote simply cannot reach it. Wrap it in OktaFocus.item (or put the action on a ListTile) — never leave an action on a bare GestureDetector on the screen surface.
  • You could not swallow the menu key even if you wanted to, and OktaFocus did not change that. Intercepting raw key events is structurally impossible from your side, not merely discouraged: the only key that reaches your code is select, and no KeyEvent object crosses into the sandbox at all. The arrows keep moving focus, and the menu key (Menu on the remote, or F1/Escape on a keyboard) and the back key always belong to the host and open its menu (the launcher, reload your app, settings) — the one way out of a lone app running in kiosk mode (auto_launch), where there is no "back" because there is nothing else to go back to.

And a passive screen with no buttons is a perfectly legitimate shape (a roll call, a notice board): the menu key keeps working because the host holds a fallback focus whenever nothing on your page holds one.

No MediaQuery and no LayoutBuilder — neither is available (the first is not bridged, the second fails to compile like the rest above). Your screen therefore cannot measure its own canvas at all, so design straight to the fixed footprint: a 1080p television at ~320dpi reports a logical size of about 960×540, not 1920×1080 — which is why the examples above use type larger than you would on a phone (36pt is not an arbitrary choice).

auto_launch describes your intent, not a guarantee. The screen's own administrator holds a device-level switch ("always show the home screen") that overrides auto_launch for every app on that particular screen, so it always opens onto the grid of installed apps even when your app is the only one declared there. Do not assume your app will simply appear with no interaction: build its first screen so it reads fine whether the user arrived there automatically or by tapping a tile on the grid — which is exactly how it gets opened from the simulator and during review anyway.

Designing the screen surface — sizing, layout, and the back button

A screen is seen from across a room by many passing eyes, not held close by one — that flips the usual phone-design priorities:

  • Design for the real baseline of roughly 960×540 logical px (a 1080p television at ~320dpi), not a phone canvas scaled up. Build for this footprint directly rather than designing a phone layout and stretching it by a fixed factor — proportions that read as balanced on a phone go either empty or crowded on a screen with a completely different, wide-and-short 16:9 aspect ratio.
  • Leave a safety margin from the edges (overscan): some real TV panels — older ones especially — physically crop a few pixels off every edge. Never place text or an interactive element flush against the screen's edge; leave at least 24-32dp of margin around the entire content area.
  • Type much larger than you would ever use on a phone — it is read from the back of a classroom or a lobby, not arm's length. 36pt, used in this chapter's examples, is a reasonable floor for body text, with headings well above it. Use strong contrast between text and background (not close shades of grey) — read colours through OktaPalette.of(Okta.isDark()) rather than hard-coding them, so your app follows the screen's own light/dark mode, which its administrator sets from that device's own settings, not from your app.
  • Few, large interactive elements — not a dense small grid: navigation is entirely direction-key input on a remote, so every extra tile in a grid is one more press for someone with no touch to jump straight to it. Prefer a short list of large cards over a crowded grid of small ones. The focus ring is drawn for you — by Flutter on stock widgets, by the host on OktaFocus tiles — so do not replace it with a faint hand-rolled highlight that is hard to make out from across a room.
  • Keep information density low, generally: a screen is usually a passive display (a roll call, a notice board, a live tally) glanced at in passing, not a dashboard someone studies closely. One clear message or one readable list beats four stat cards, a table and a chart crammed onto one page.

The remote's back button belongs to the host, not your app. The physical "back" key (Back on a TV remote, or Escape on a Windows keyboard) is intercepted entirely at the host level before it ever reaches your own widget tree, and its behaviour is fixed by how many apps are installed, not by your code:

  • A single installed app (kiosk): back is swallowed completely — nothing happens, deliberately, so a passive display can't be nudged off its state by a stray press. The only way out is the menu key (see the previous section).
  • More than one app: back sends the user straight to the app grid (the launcher) — your widget tree is never consulted and gets no notification of this at all.

So do not build internal navigation (a list drilling into a detail view, say) that depends on the physical back key to go up a level — that event never reaches your code in the sense phone apps usually assume. If your screen needs a way out of a detail view back to a list, put a visible "back" control on the screen itself — a stock button or a ListTile, not a bare GestureDetector (see the remote-reachability rule above), placed first in reading order, or carrying OktaFocus.first — and drive it from your own widget state (setState), not from Navigator.

Trying the screen on the virtual device (the simulator)

You do not need a television to try it: the portal's «Virtual device» (the app's tab, or /dashboard/simulator) mounts the very same screen surface in the browser — the same dart_eval engine your code is compiled on by a display, and the same source bundle from your branch.

  • The «Display (Okta Screen)» surface appears in the surface picker when the version declares mobile.screen, and is selected automatically for an app that has no phone surface at all.
  • The place comes first: one row per declared place (organisation screen / classroom screen), each naming its own entry file. The simulator compiles the chosen place's file alone — exactly as a display does — so an app serving both places is tried twice, one package per place.
  • The frame is a 960×540 logical television (dpr 2, no safe-area insets) — the size an Android TV really reports, not a phone turned sideways. The device panel offers other sizes for comparison, but the layout is judged at this one.
  • Okta.context() exactly as a display writes it, key for key: screen (tenant|section), for a classroom section_id and section_name, and an empty role_id because nobody signs in to a screen. The classroom is simulated (sim-section / «Simulated classroom») — from the «Persona» panel you can type a real sandbox section's ULID and name if you want to follow its data. Changing the place or the section is a relaunch, not a repaint, because on a display too the context is injected at startup.
  • Branch source only: «Installed on sandbox» is disabled for a screen — no display is paired to the sandbox to download its bundle from.
  • The remote is your keyboard: arrows and Tab move focus, Enter is OK. What an arrow cannot reach in the simulator, a remote cannot reach on the television either (see the D-pad section).
  • A limit you must know — data is NOT pinned to the classroom here. /api/apps calls in the simulator go through the sandbox seat (an admin account), not a screen device token, so education/students returns the whole school, not the classroom. On a real display the server pins the section onto the context (sectionScopeId) and nothing else gets through — a server guarantee that does not depend on your code, and one that can only be tested on a paired screen. In the simulator, filter by section_id yourself if you want to see what the classroom will see.
  • From MCP: simulator_start {slug, surface: "screen", screen_scope: "section"} (or "tenant"), then simulator_screenshot / simulator_ui / simulator_tap as for any session — the tool's reply names the place, the simulated classroom and the data limit above.

When your app fails on a screen — the error code and platform reporting

A screen nobody stands behind must neither go silently white nor show a red error tree to a whole classroom. So when your app fails — a compile error, an exception while building, a load timeout — the display shows a status screen carrying an error code of the form MA-XXXXXXX (MA = mini-app, then a fingerprint derived from the error's type and text — the same algorithm as okta-app's codes, so the same failure yields the same code on every device). The operator reads it off the television and reports it to you.

  • The code reaches the platform on its own: the display reports the failure to okta-web once per code per session (POST /api/device/client-errors with the paired device's token), with the message, the stack, your app's module_slug and the device's identity — so the failure is attributed to your app, not to the screen.
  • You read it on your app's «Errors» page in the portal, or through the MCP tools: recent_errors for the latest, app_errors for the triage list, and get_error with the code the operator read — from a code on a television to the full stack with nobody in between.
  • Repeats fold: failures with the same fingerprint land on one row with a counter (occurrences_count), so a hundred screens showing the same failure are one row, not a hundred reports.
  • What is not reported: an app that runs and shows something wrong is invisible to the platform — the report is for failures that prevent drawing. The way out on the device itself is the host menu (menu key → reload the app).

From the editor and MCP

  • Simulator: the «Display» surface on the virtual device (section above), and from MCP simulator_start with surface: "screen" and screen_scope.
  • Editor: the "In-Okta-app services" card → Display screens section — a checkbox per place that reveals its own entry file and its own title, and visible even with the phone surface switched off.
  • MCP: set_mobile_screen writes the block (a full replace, or null to remove it) and runs the publish checks early; get_mobile_surface reads it back and prints a line per place with its entry.
  • Repo sync (sync_from_manifest) carries it exactly as it carries dashboard.

native mode — a mini-app written in Dart

You write real Dart — no JSON, no DSL. The Okta app downloads the source, compiles it on the device (once per published version, then caches it) and renders the widget your main() returns, with the full platform identity.

No compiled artifact ever leaves your repository. On publish, okta-web reads every .dart file under okta_app/native/<entry>/lib/ into a single signed source bundle, so what is reviewed is exactly what runs.

Layout (shipped ready in the boilerplate)

okta_app/native/main/
├── pubspec.yaml           ← pins okta_miniapp — do not bump the ref
├── analysis_options.yaml
├── lib/
│   └── main.dart          ← entry point: Widget main()
└── tool/
    └── validate.dart      ← the same gate CI runs

The entry is okta_app/native/main/lib/main.dart — a .dart file under lib/ only, no ... Enforced both at save and at publish.

The smallest thing that works

import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';

/// Okta calls this to obtain your root widget.
Widget main() => const HomeScreen();

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // The scalars, not `Okta.context()`: that returns a decoded **map** with
    // snake_case keys, so `ctx.tenantId` does not exist and `ctx['tenantId']`
    // is a missing key — and reading a missing key is the nastiest trap in
    // this runtime (below).
    return Scaffold(
      appBar: AppBar(title: const Text('My app')),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('Tenant: ${Okta.tenantId()}   Role: ${Okta.roleId()}'),
          ElevatedButton(
            onPressed: () => Okta.toast('Hello'),
            child: const Text('Say hi'),
          ),
        ],
      ),
    );
  }
}

The host contract — package:okta_host

Okta.* is the only way out of the sandbox. The current contract is 25.

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 — a closed vocabulary, each with a haptic 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

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() and Okta.toolKeyReads() hand back the key in the same canonical form okta-web stores: lower-case, no separators (:, -, spaces), no control characters. A reader that emits AA:BB:CC and one that emits aabbcc both reach you as aabbcc.

Compare directly. Do not .toUpperCase() or .toLowerCase() first: it would not hurt — the normalisation is idempotent — but it hides any future drift instead of surfacing it, and it makes your code look like it knows something about the form that it does not.

If your app carries a workaround for this, delete it. Okta.scanNfc() used to return raw UPPER-case while the tool-key path returned the canonical form, so a card enrolled on the tool screen did not match itself when read through scanNfc. It never looked like a fault: the scan succeeded and the lookup missed, so the app reported an unregistered person. Fixed in the host; the two surfaces agree now.

The same applies to the WebView bridge: the nfc event now carries the canonical form where it used to carry UPPER-case.

The embedded scanner: cameraScanStart arms, cameraPreview opens

Okta.cameraScanStart() arms the camera; it does not open it. The lens opens when you mount Okta.cameraPreview(size) in your tree, and the preview is not an optional display — it is the source of the reads. An app that arms the scanner and never mounts the preview gets nothing from cameraScanReads(), ever.

The order:

cameraScanStart()  →  mount cameraPreview(size)  →  drain cameraScanReads()

And true means "armed", not "open": what is settled before it returns is the OS camera permission, which is what false reports. Everything after that is decided by mounting the preview.

This changed in the host. cameraScanStart used to try to open the lens itself, and it failed on every device: the package refuses to start before the preview widget is built, and the preview was only built after a successful start — a closed loop that made the embedded scanner answer false from contract 15 onwards. If you tried it once and gave up, try it again with the order above.

A gate that works with no network — the roster and the fingerprint (contract 20)

GET /tool-keys/resolve asks the platform "whose card is this?" — and needs a network. At a school gate at 7am, the moment you most need an answer is the moment the network is what failed.

So the roster is downloaded ahead of time:

GET /api/apps/tool-keys/students        scope: tool_keys.links.read
{
  "students": [
    {
      "student_id": "01J2X…",
      "full_name": "…",
      "grade_id": "01J2A…",
      "section_id": "01J2B…",
      "fingerprints": ["9f2c1d…", "4b70aa…"]
    }
  ]
}

The rows carry fingerprints, not keys, and that is not a restriction — it is what made the roster possible at all. An NFC tool key is the card's UID, so a roster of raw keys is the means to write a card that opens the gate as any pupil on it. A fingerprint matches; it does not manufacture.

So do not compare a scan to the roster directly. Put it through this first:

final reads = await Okta.toolKeyReads();
for (final read in reads) {
  final fp = await Okta.toolKeyFingerprint('${read['key']}');
  if (fp == '') {
    // Could not fingerprint — no keying material cached and no network to
    // fetch it. This is NOT "card not registered"; do not treat it as one.
    continue;
  }
  final student = rosterByFingerprint[fp];   // the roster you downloaded
  …
}

Three properties you can rely on:

  • Stable for one card at one school — the same value every time, and the same whether an NFC or a UHF reader produced the key.
  • Different at another school for the same physical card. Never store a fingerprint from one organisation and compare it at another.
  • The keying secret never reaches your app. The host holds it and fingerprints on your behalf. If it reached you, every four-byte UID could be generated and the roster turned back into the cards — which is the thing the fingerprint exists to prevent.

The empty string is not "unregistered". toolKeyFingerprint answers '' when it could not fingerprint — no keying material yet, or a portal session with no organisation. An empty fingerprint compared against the roster matches nobody, so at a gate it looks exactly like a pupil who was never enrolled. Check for it explicitly and tell the operator the device could not verify, rather than that the pupil was refused.

A student with no card appears in the roster with an empty list rather than being left out: "not enrolled yet" and "not at this school" are different answers, and a roster of only card-holders gives both the same silence.

Declare minContract: 20 if you call toolKeyFingerprint. It is a new symbol in an injected library and your app compiles on the device, so calling it against an older host dies with Cannot find static method, naming a file you never wrote. The roster itself is an ordinary HTTP call and needs no floor.

Note for existing apps: this roster sits under the same tool_keys.links.read scope your app already holds if it uses resolve. No new scope and no re-consent from organisations — but its meaning widened: it was "confirm a card handed to you", and now also covers "download the list of who holds one".

Calling above the device's contract does not fail — it poisons the session

Every row with a bold contract above is backed by an external function the host registers. On an older host it is not registered, and invoking it does not return an error — it corrupts the interpreter for the rest of the session: later calls fail with nothing connecting them to the cause, and you hunt a bug in code that is fine.

Two answers; pick one per call:

  • Declare minContract equal to the highest number you use, so Okta shows "update the app" instead of running you on a host that cannot serve you; or
  • Guard at the call site: if (Okta.contract() >= 11) { … }, with a fallback.

The scalars, get/post, toast and scan* need no guard — they have been there since contract 1.

The floating bottom bar — OktaTabs.floatingBar (contract 18)

Okta's own bottom bar, inside your mini-app: a rounded strip inset from the screen edges, floating over the content — the same silhouette the Okta app itself navigates with. Just using the widget is enough:

final labels = <String>['Home', 'Register', 'Reports'];
final icons = <IconData>[OktaIcons.home(), OktaIcons.list(), OktaIcons.chart()];

Widget main() {
  final palette = OktaPalette.of(Okta.isDark());
  return OktaTabs.floatingOver(
    content,                                                   // your page
    OktaTabs.floatingBar(labels, icons, _index, _onSelect, palette),
  );
}

floatingOver lays the bar over the body (Scaffold here has no bottomNavigationBar slot, so the composition is a Stack — and this method IS that Stack so no app rebuilds it). Give your scrolling child a bottom padding of OktaTabs.floatingReserve() so its last row can rise clear of the bar.

Customisation is layered — in the same shape as the rest of the class (separate methods, never optional parameters — dart_eval 0.8.5 crashes on an omitted optional of an injected-source static):

Method What it controls
floatingBar(labels, icons, selectedIndex, onSelect, palette) identity defaults from the palette
floatingBarWithDots(… , dots, …) + an unread dot on any tab flagged true (a short list is safe)
floatingBarTinted(… , background, ring, activePill, activeInk, idleInk) every colour in your hands

Pass colours as Color OBJECTS built from literals at your call site (const Color(0xFF6D428F)): a Color crosses interpreted parameters intact, while a raw int passed along and then fed to a bridged constructor arrives double-boxed and dies with a cast error.

Solid, not glass — deliberately: BackdropFilter is not bridged and BoxShadow is declared nowhere on this runtime, so the bar's depth cue is a one-pixel ring in the border colour — geometry, not a Border (the bridge declares that parameter as BoxBorder and never declares Border as its subtype).

Contract 18. Calling this family on a 17 host dies inside okta_kit with Cannot find static method, naming a file you never wrote. Declare minContract: 18, or guard with Okta.contract() >= 18 and fall back to the old bottomBar.

flutter analyze will flag Okta and package:okta_host as undefined — that is expected. The library is injected by the runtime at compile time and is not a real pub package. The authoritative gate is flutter test tool/validate.dart.

What Okta.get / Okta.post return — and how to consume it

Every HTTP call returns an OktaApiResponse: three fields and one derived getter.

Field Type What it holds
status int The HTTP code — and 0 when the call never reached a server (network down, or a path the allow-list refused)
body dynamic The decoded JSON body — or the raw string when it is not JSON
error dynamic A transport/permission failure description; null when none occurred
ok bool (derived) error == null and status in 200–299

Checking ok is enough for the happy path: it already combines "no transport failure" with "a success status", so you never need to write status >= 200 && status < 300 yourself.

final res = await Okta.get('/api/my-app/students');

if (!res.ok) {
  // status == 0 means the call never reached a server — do not show "the
  // server said…" there, because the server said nothing.
  Okta.toast(res.status == 0 ? 'Could not connect' : 'Server said ${res.status}');
  return;
}

// body is dynamic — keep it that way. See below.
final dynamic payload = res.body;
final dynamic rows = payload['data'];

if (rows is List) {
  for (final dynamic row in rows) {
    final dynamic name = row['full_name'];
    // …
  }
}

The indexing above assumes your endpoint returns a JSON object — it is your endpoint, so that assumption is yours to make. If it answers with something that is not JSON, body arrives as a raw string and indexing it with a String key fails at runtime. You cannot guard with if (body is Map): that guard promotes the receiver and walks you into the same indexing trap.

body is dynamic on purpose, and that is not a detail. If you "tidy it up" by giving it an explicit Map type:

final Map data = res.body;      // ← don't
final rows = data['items'];     // Cannot use variable of type String as index
                                //   to map of type <int, Color>

you walk straight into the Map-indexing trap described in the dart_eval list above, at compile time. The field arrives dynamic from the host for exactly this reason: leaving it alone is the correct thing to do, not the lazy one.

error versus status: error is for everything below HTTP — the network dropped, or the allow-list refused the path so nothing was ever sent. A server that answers 4xx/5xx arrives with error == null and the code in status. Both make ok false, but they are different failures to a user: one is "no connection", the other is "the server refused".

Okta.uploadFile does NOT — it returns a decoded map, not a class. Read it with ['status'], ['body'], ['file_name'] and ['error'] (note the underscore in file_name), and it is null when the user cancels the picker, so check for null before you touch the keys. There is no .ok here and no .fileName.

Why is this different from Okta.get? Because OktaApiResponse is named by Okta.api itself, and every app calls api, so the class always resolves. An injected library may not name a class your app never reaches: dart_eval resolves such a class only when the source pulls it in, so for an app that never uploads a file the name does not exist — and the failure lands on the injected library, naming a file the partner never wrote: CompileError: Unknown type OktaUploadResult. That is why uploadFile, context, location and preciseLocation all return maps.

final dynamic up = await Okta.uploadFile('/api/my-app/attachments');
if (up == null) return;                       // the user cancelled
final dynamic status = up['status'];
if (status is int && status >= 200 && status < 300) {
  Okta.toast('Uploaded ${up['file_name']}');
}

Validate locally — the same gate CI runs

cd okta_app/native/main
flutter pub get
flutter test tool/validate.dart

This compiles your code against the exact device runtime, so anything outside the supported subset fails in CI rather than on a user's phone.

And pub get itself may refuse before anything reaches CI. pubspec.yaml carries a ceiling on the Dart SDK version (not Flutter's — see the environment: comment in the file itself; pub does not enforce the Flutter bound on the root package, measured rather than assumed) because okta_miniapp hand-writes class $Container implements Container, and Flutter 3.41 added a member (Container.isAntiAlias) the pub.dev release does not know about. A newer Flutter is refused by pub get with one line naming the version to install — not a false "Got dependencies!" followed by a compile failure later.

The supported dart_eval subset — read this before you write

The device runtime is dart_eval + flutter_eval, which support a subset of Dart/Flutter. The list below is not a style preference — every item breaks:

  • Host calls through the static Okta.* facade only — never top-level host functions.

  • No State.mounted — it is not bridged. Call setState directly.

  • Callbacks must be closure literals, not method tear-offs: onPressed: () => _doThing(), never onPressed: _doThing ("Cannot box Function").

  • Never pass cond ? null : closure to a callback slot. Pass an unconditional () => … and guard inside the method (if (_busy) return;).

  • Index JSON on a dynamic receiver — never through a Map-typed one. body['key'] compiles and dispatches correctly while body is dynamic. Give that receiver a raw-Map static type — an is Map guard promotes it, or you annotate a variable, parameter or field Map — and operator[] switches to static resolution. dart_eval 0.8.5 then binds raw Map to an arbitrary bridged instantiation (Map<int, Color>, MaterialColor's swatch shape) and rejects your String key at compile time: Cannot use variable of type String as index to map of type <int, Color>. The <int, Color> never comes from your data — don't go hunting for it. Keep JSON dynamic end to end (final dynamic v = body['key'];) and guard on the result (v is List), never on the receiver you are about to index. is List promotion is fine — only Map is affected.

  • No nested loops. dart_eval 0.8.5 throws a RangeError at compile time when a loop's body — directly, or through a method it calls — contains another loop. Flatten hierarchical data into one flat list first (on the server, ideally), then run ONE loop over it with a loop-free row builder.

  • Buttons: ElevatedButton / TextButton onlyFilledButton and OutlinedButton are not bridged.

  • Layout: Row / Column (+ Expanded). Wrap and AlignmentDirectional are not bridged — build grids N-per-row with Row+Expanded, and reach for Directionality (not Align + AlignmentDirectional) for RTL.

  • Always pass flex: to Expanded / Flexible. The bridge does not apply the parameter's default, so an omitted flex arrives as null and is cast to inttype 'Null' is not a subtype of type 'int', thrown while the widget builds. Write Expanded(flex: 1, child: …). The same holds for any bridged widget whose optional parameter is a non-nullable int.

  • BoxDecoration carries only color and boxShadow. borderRadius, gradient, image and shape sit in the bridge commented out, and an argument the bridge does not declare is silently discarded rather than rejected — it compiles clean and the value simply never reaches Flutter. So BoxDecoration(borderRadius: …) paints square corners with no error anywhere, and validate.dart cannot catch it because nothing fails. Round with ClipRRect(borderRadius: …, clipBehavior: Clip.antiAlias, child: ColoredBox(…)).

  • BoxDecoration(border: …) does not compile at all. The parameter is declared, typed BoxBorder — but the bridge never declares Border as a subtype of it, and the compiler knows only what the bridge declares. An ordinary Border.all(…) fails with Cannot assign argument of type Border to parameter of type BoxBorder. Draw a hairline as a 1px box: Container(height: 1.0, color: …).

    Those last two are the same trap from opposite ends: an argument the bridge does not declare is dropped without a word, while one it does declare is type-checked without mercy.

  • No arbitrary Icons.* constants — use text or labels instead.

  • Prefer simple Map/List shapes over deep generics across the host bridge.

When your change reaches the device — and when the old copy stays

The Okta app compiles the source on the device once, then caches it. The key is not the contents of the bundle — that is the whole point. It has four parts:

Part Moves when
slug a different app
entry a different entry package under the same slug (see "Several entry packages" below)
payloadVersion the module row's updated_at moves — and every install carrying a new commit moves it
runtimeSignature a library the host injects changes, i.e. with a new Okta app release

On sandbox: push, then reinstall — do not bump the version

You do not need a new version for every change. The loop is:

  1. push your code to the version's branch;
  2. reinstall the same version on sandbox.

The install re-resolves the branch's commit every time (the commit_resolve step), so okta-web receives a different commit, the module row goes dirty, updated_at moves, payloadVersion changes, and the device's cache misses and recompiles. install_on_sandbox accepts any existing version, not only editable ones.

When the old copy really does stay

When none of the four moved. In practice there is one case: you reinstalled without pushing — same commit, nothing dirty, so the source is not re-pulled and nothing recompiles. That is correct rather than broken: there is nothing new to deliver.

This misleads more than it blocks. No error, no warning — you open the app, see exactly the old behaviour, and conclude your fix did not work. So the first thing to check is: did your push actually land on the branch the version points at? Not the code.

The bundle is built compressed at install — and rebuilt when it goes stale

Since compression was enabled the bundle is no longer built per launch. okta-web builds it once at install for each native entrypoint, gzips it and stores it, so a launch serves a file instead of walking your tree again. The Okta app downloads it, decompresses it and runs it.

The file is no longer frozen. It used to carry min_contract and capabilities as they read at install time and not move until you reinstalled — and that gave partners the worst debugging day there is: you publish a change, the version number moves on every device, and the old code keeps running behind it looking perfectly current. okta-web now compares the file's age, on every launch, against the tree it can actually deliver: once your new code has landed on the platform, one rebuild fires and the fresh file is served. And while a publish's code download is still in flight (or has failed), devices keep being offered the previous version — its number and its file together — never a new number wrapping old code.

What the portal shows is what devices receive on their next open — no manual reinstall for a manifest that moved.

Your normal loop is unchanged: push, then reinstall when the files change (pulling your source is still what an install does); a manifest-only edit now arrives on its own.

And on production

Tenants receive what is published. There a new version is the mechanism — not because caching works differently, but because nobody reinstalls on their behalf.

Publishing is two steps under the hood: the platform records your version, then downloads your code with a one-hour token. Devices are only ever told about the tree that actually landed — and a stalled download no longer stays stalled: okta-web retries it automatically with a freshly minted token, from the first device launch that meets the wedge and from a scheduled sweep every ten minutes. So "the portal says 1.4.0 but phones still offer 1.3.0" for a short while after publishing is the system being honest, not your update being lost; if it persists, open the module's status — it carries the exact pull error.

Conversely, an Okta app update moves runtimeSignature and recompiles your app on every device without you publishing anything.

This misleads more than it blocks. There is no error and no warning — you open the app, see exactly the old behaviour, and conclude your fix did not work, so you start undoing it and trying other solutions to the wrong problem. In fact your fix never reached the device at all.

If you see old behaviour after a change you trust, check the version number before you check the code.

min_contract — the host-contract floor

The host contract is versioned. Declare the floor your version depends on and the Okta app shows "update the app" instead of running a mini-app the device is too old for.

  • Declare the floor the host features you call actually need. (The old "≥ 13 because you declared a device capability" rule is gone with the gate.)
  • Applies to native only; the editor clears it in any other mode.

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" }
  ]
}

runtime: "dart" is added automatically in native mode — do not write it yourself.

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" },
  { "key": "family", "mode": "native", "portal": "guardian",
    "entry": "okta_app/native/family_app/lib/main.dart" }
]

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.

When the app will not open at all — the payload failure family

The card appears, you tap it, and loading fails before any of your code runs. This is not a bug in your mini-app: the launch endpoint succeeded (so the install, the scope and the audience are all fine) and the source-bundle download then failed.

The entry shape is enforced three times, not two: on save in the version editor, on publish in okta-web, and once more when the bundle is served — and that third one is what you see as a failed load. Every possible cause is on the server side:

Cause What it means
The module is not deployed as code on this instance The database row exists; the code is not on disk
okta_app/native/<entry>/lib is missing from the deployed module The module shipped without its native tree
The entry file is not under that lib/ entry points at a file that does not exist
entry does not match okta_app/native/<entry>/lib/**.dart An old version grandfathered in before the shape gate
The bundle > 10 MB, or > 128 files You are over the assembly limits

There is no per-file limit. It was 256 KB and was removed: the compile and the transfer only ever see the total, so splitting a file served nothing. What remains is 10 MB for the whole project and 128 files, and the refusal names the file being read when the budget ran out.

The 10 MB ceiling is almost always hit by data, not logic. A Dart project that reaches it nearly always carries generated files: base64 images, huge const maps, generated translations. Move the data out of Dart and behind an endpoint you read with Okta.get — what arrives over the network is not compiled on the device, while every byte in your bundle is. This is not about transfer: the bundle travels compressed, but the on-device compile works on the full source, and it runs on the isolate that draws the UI.

What to do: report your app's slug and the time you tried to the platform team. The server logs the exact refusal, and the app shows it as text on screen in non-production environments — so the answer exists on one side or the other and does not need guessing. The first thing to check yourself: that the entry path in your manifest matches a path that really exists in your repo, character for character.


Device capabilities — REMOVED, being rebuilt

A second permission axis stood here: mobile.capabilities[] in the manifest, a closed vocabulary of eleven device resources, a publish-time check, and a gate inside the Okta app that refused any call an app had not declared and the person had not consented to. The whole thing has been removed — vocabulary, gate, consent ledger, permissions screen, per-channel reader filter — and is being redesigned from scratch rather than patched.

What that means for you now:

  • Do not declare mobile.capabilities in a new manifest. The key is still ACCEPTED so that no already-published app fails its next publish, but nothing reads it: it is not normalised, not carried to the device, and not shown on the install screen.
  • Every device call simply works. Okta.scanNfc(), Okta.scanBarcode(), Okta.toolKeyStart(), Okta.location(), Okta.preciseLocation(), Okta.uploadFile(), Okta.openDocument() and storage need no declaration and raise no platform prompt.
  • The operating system still asks. Android and iOS show their own runtime dialog the first time your app touches the camera, location or NFC, and the person manages it in device settings. That was never part of this gate.
  • Okta.hasCapability() / Okta.requestCapability() still compile and now always answer true. They are kept because they are compile surface — a published mini-app that calls one would fail to compile ON THE DEVICE if the symbol vanished — so existing guards keep working and always take the granted branch. Write no new guards against them; the replacement re-uses these two names.
  • There is no "minContract ≥ 13" rule any more. Declare minContract for host features you actually depend on, nothing else.
  • A refusal is still not an exception. A device call can still answer null / false / a negative accuracy — absent hardware, an OS prompt the person declined, a reader that never paired. Keep checking return values; that discipline never depended on the gate.

The host contract moved to 19 for this. A device still on 18 or below enforces the OLD gate, and an app written for the ungated world is refused there silentlyscanNfc answering null with nothing in any log — rather than with a compile error you could see.

Your tab in the student profile (mobile.student_profile_tabs) — native mode only

A guardian opens the Okta app, sees their children, taps one — and the screen becomes the student profile. This declaration gives you your own tab inside that screen: you own its whole body, your information and your actions and your layout, not a card in a template you fill in.

"mobile": {
  "supported": true,
  "mode": "native",
  "minContract": 17,
  "student_profile_tabs": [
    {
      "key": "attendance",
      "title": { "ar": "الحضور", "en": "Attendance" },
      "entry": "okta_app/native/student_tab/lib/main.dart",
      "portals": ["guardian"],
      "order": 10
    }
  ]
}

This is not the studentProfile block. That one adds panels, stats and actions to the student-profile page in okta-web, read by staff, and is discovered from Livewire classes in your repo. This is a native mini-app mounted on a guardian's phone. Two audiences, two hosts, two runtimes — so they are not one array. See Extending the student profile for that one.

And it is not an account type. mobile.audiences[] answers who opens your app; this answers where a surface mounts. Merging them would make "guardian" mean two different screens depending on a sibling field.

Fields

Field Required Note
key ^[a-z][a-z0-9_-]{0,63}$. An identity, not a label: the platform remembers the guardian's last-opened tab by it, so changing it forgets their choice.
title {ar, en}; Arabic is required. A bare string is read as the Arabic title. Up to 60 chars.
entry okta_app/native/<package>/lib/<file>.dart. May be the same file as your portal card, or its own package.
portals ["guardian"] by default. "student" is the same screen a student opens on themselves.
order Sort among your own tabs. Your position against other apps' tabs is the platform's to decide, not yours.
icon Up to 64 chars.

Four conditions, each of which drops the whole tab

  1. native only. The tab is handed the student's identifier. Under webview or external that identifier would have to be posted to a partner-hosted page — moving a child's id onto your server every time the screen opens, outside the Okta.* calls okta-web authorizes one by one. A declaration under any other mode is dropped.
  2. minContract ≥ 17. The mount point did not exist before that contract. An older device does not error and does not complain — it renders the profile with your tab absent, and nothing in any log says why.
  3. The education.students.read scope. The tab receives only the student's hashid; without this scope it cannot turn that into a student it knows, so it mounts and shows nothing.
  4. Three tabs maximum. The strip is shared: every app the tenant installed puts its tab in the same row, in front of a parent who came to see their child. An app taking eight seats is not extending the profile, it is annexing it — and the guardian pays in horizontal scrolling.

What you receive at mount — and how to read it

Your tab is mounted for one student, and you receive the student's hashid, never a numeric id. Not cosmetic: a sequential integer would let an app walk the school roster by incrementing; a hashid would not.

Two calls, new in contract 17:

// The student the tab was opened on. A function, not a property.
final studentHashid = Okta.studentHashid();

// Who is reading: 'guardian' or 'student'. One screen, two readers.
final portal = Okta.portal();

Both answer '', never null, when the surface is not a student-profile tab — the ordinary case on every other surface of your app. '' is not an error; it means "this is not a student-scoped mount". Do not fall back to "the current user" there: in a guardian's context there is no student in the session to fall back to, and an app that guesses one shows a parent another family's child.

Why '' and not null? Because '$value' on a null produces the four characters null — a perfectly well-formed string that walks straight into an API path with nothing to stop it. An empty string is checkable; 'null' is not.

Authorization is not yours to do and not yours to bypass. okta-web re-runs it on every call (a linked guardian, the student themselves, or a delegate with an effective delegation), so a tab reaching for another student gets a 403 rather than data. Do not build your own check, and do not stash the hashid to read later in some other context.

A complete, working example

okta_app/native/student_tab/lib/main.dart — an attendance tab listing the student's absences:

import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';

Widget main() => const AbsencesTab();

class AbsencesTab extends StatefulWidget {
  const AbsencesTab({Key? key}) : super(key: key);

  @override
  State<AbsencesTab> createState() => _AbsencesTabState();
}

class _AbsencesTabState extends State<AbsencesTab> {
  bool loading = true;
  String error = '';
  List<dynamic> rows = <dynamic>[];

  @override
  void initState() {
    super.initState();
    load();
  }

  Future<void> load() async {
    final sid = Okta.studentHashid();

    // "Not a student-scoped mount" is a legitimate state, not a failure —
    // say something a reader understands instead of calling the API with an
    // empty identifier.
    if (sid.isEmpty) {
      setState(() {
        loading = false;
        error = 'Open this tab from one of your children’s profiles.';
      });
      return;
    }

    // Your own namespace. okta-web has already checked this reader may see
    // this student before the call reaches your code.
    final res = await Okta.get('/api/my-app/absences?student=' + sid);

    setState(() {
      loading = false;
      if (res.ok) {
        rows = res.body['data'];
      } else {
        error = 'Could not load the record.';
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    if (loading) {
      return const Center(child: CircularProgressIndicator());
    }
    if (error != '') {
      return Center(child: Text(error));
    }
    if (rows.isEmpty) {
      // "No absences" is good news to a parent — say it, don't render an
      // empty table.
      return const Center(child: Text('No absences this term. 🎉'));
    }

    // "Your child" to a guardian, "you" to the student: one screen, two
    // readers.
    final who = Okta.portal() == 'guardian' ? 'Your child' : 'You';

    return ListView(
      children: [
        Padding(
          padding: const EdgeInsets.all(12.0),
          child: Text('$who missed ${rows.length} day(s) this term'),
        ),
        for (final row in rows)
          ListTile(
            title: Text('${row['date']}'),
            subtitle: Text('${row['reason']}'),
          ),
      ],
    );
  }
}

Three things worth noting in this example:

  1. main() returns a Widget, like any other mini-app — a tab is not a new kind of program, it is the same surface with a different mount point. Everything you know about native applies: the supported dart_eval subset, the limits, and the traps below.
  2. The empty state is not an error. Someone who reached the tab from outside a student profile deserves a sentence explaining that, not a spinner that never stops and not an API call with a blank identifier.
  3. Never show a parent an empty table. "No absences this term" is the information; it is what they came to find out.

Your scopes, and the platform's data

education.students.read is required because the tab receives an identifier and nothing else. If you need more than turning that into a student — the grade, the section, the timetable — request the matching scopes as described in Scopes. Your OWN data comes from your own namespace (/api/<slug>/…) with no extra scope, and it is usually most of what the tab shows.

Traps

  • A dropped tab leaves no trace. Any of the four conditions above drops the row at build time: the manifest publishes, the app installs, and the tab is simply not there on the parent's phone. That is why the version editor and validate_manifest refuse the row with an error, not a warning — a refusal where you read it beats an absence you never see.
  • Empty is worse than absent. A tab with an appealing title that shows nothing until data is set up is better left undeclared. Declare it when it has something to say.
  • The title has exactly one audience: the guardian. Do not put your app's name or an internal term in it — your app's name is already shown next to the tab.

Traps the devices proved — read these before you ship

Every item here took down a real app on a real phone, and none of them is caught by flutter analyze or tool/validate.dart. Compilation succeeds; the interpreter then dies in the user's hand.

1. Okta.* is a BRIDGE CALL, not a field read — cache it once. Every call round-trips to the host and comes back as JSON to be decoded again. Every time. One attendance app wrote:

bool get _ar => Okta.locale().startsWith('ar');   // a getter — 188 calls per build
String _t(String ar, String en) => _ar ? ar : en;

Read the identity once in initState and keep it in fields:

late final bool _ar;
@override
void initState() {
  super.initState();
  _ar = OktaText.isRtl(Okta.locale());
}

It cannot change without the host rebuilding the mini-app anyway. The rule is wider than identity: treat every Okta.* as network, not as a property.

1b. A value that ARRIVED from outside is not an interpreter value — and the message names the argument, not the receiver. Calling a bridged method (startsWith, toLowerCase, trim, contains, substring, even toString) on a value that came from outside the interpreter dies with:

type 'String' is not a subtype of type '$Value?' in type cast
#1  $String._startsWith

args[0] is perfectly fine — the receiver is the wrong shape, so do not chase the argument the trace names. Okta.* was fixed from the inside and no longer hands out values in that shape, but a JSON reader you write does:

String _text(dynamic map, String key) {
  return map[key] as String;        // the cast passes; the shape stays wrong
}

String _text(dynamic map, String key) {
  final dynamic value = map[key];
  return '$value';                  // interpolation makes the interpreter allocate it
}

Fix it at the mint, not at the call sites, and do the same in the int / double branches (value.toString() carries the identical defect and merely fires less often). For bools use == true rather than as bool.

2. Never nest ternaries — in an argument or an assignment alike.

final way = a ? (x ? '…' : '…') : (b ? '…' : '…');   // arrives null

The compiler allocates the outer conditional's result slot and the inner one writes elsewhere:

*L11: null, L12: null
8484: BoxString (L12)  <<< EXCEPTION

Branch and return instead. A single ternary is fine. And the type in the message (String, int) is just whatever the expression produced — do not read it as narrowing the rule.

3. An uncalled private method fails your own gate. unused_element is a warning, and flutter analyze is part of partner-module-policy. Delete a button, delete its handler with it.

4. A device call can still refuse — check what it returns. The platform's capability gate is gone, so nothing is withheld for lack of a declaration. What remains is the real world: hardware that is absent, an OS permission dialog the person declined, a reader that never paired. Every call answers in its own documented refusal shape — scanNfcnull, storePut / openDocument / toolKeyStartfalse, 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. A missing map key produces a value you cannot even inspect. 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.

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. 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) is written as a self-scheduling Future.delayed recursion, 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.

Instant messages (realtime)

A mini-app could ask, but never be told. Every capability before this number is the sandbox calling the host and waiting for an answer; there was no path for the host to call inward. So an app that wanted to know when its own server had said something had only one option: poll. A classroom screen asking "has anyone been called?" every two seconds, all day, about an event that happens twice a lesson. This channel reverses the direction — your app pushes news to its own surfaces.

Two halves, and only one bumped the contract — the difference is not a detail

Sending needed no contract bump. Publishing is an ordinary API call on an already-allowed path: Okta.post('/api/apps/realtime/publish', …) works on contract 21 with no new symbol, because Okta.post has sent since contract 1 and the partner surface is gated by scopes, not by symbols.

Receiving could not be expressed at all. No symbol in the injected library could receive a handler, and a new symbol in an injected library is a contract change: an app calling Okta.onMessage on a host at 21 does not fail gracefully — it dies on the device with Cannot find static method Okta.onMessage, naming a file its author never wrote. Hence 21 → 22.

What the number means for you in practice: an app declaring minContract: 22 is refused on an older host with an "update the app" screen — which is the correct refusal, because the alternative is a compile death on a television in a school. A host at 22 runs everything that came before exactly as it did; nothing in contract 21 changed.

Declare minContract: 22 only if you receive. An app that publishes but never listens stays on its current floor and keeps working on every device in the field — and raising the floor without needing to does not make your app newer, it makes it refuse devices it used to run on.

Publishing — POST /api/apps/realtime/publish

Behind the realtime.messages.write scope. Declare it in scopes like any other.

await Okta.post('/api/apps/realtime/publish', {
  'event': 'class.called',
  'data': {'section': 'A-3'},
});

The body takes two keys and no others: event and data. A 202 answers:

{
  "published": true,
  "channel": "private-app.01M1TTHDSWKEXCCATPVA7MP8N5.roll-call",
  "sent_at": "2026-09-06T07:39:46+00:00"
}

And this is what reaches your surfaces on the channel:

{
  "event": "class.called",
  "data": {"section": "A-3"},
  "sent_at": "2026-09-06T07:39:46+00:00",
  "from": "server"
}

Your event name never becomes the broadcast event name. The broadcast event is the fixed string app.message for every message from every app, and class.called travels inside the payload under event. Broadcasting under your name would let an app broadcast device.command.queued — or any name a platform surface binds on — and a client would act on a message a partner wrote. One frame: the client binds once, and your vocabulary is data.

Refusals — and what to do about each

Status error What happened, and what you do
403 scope_not_granted realtime.messages.write is not granted to this installation. Declare it and republish; the tenant grants it on update.
422 invalid_event The event name does not match ^[a-z][a-z0-9_.:-]{0,63}$ — lowercase, starting with a letter, at most 64 characters.
422 payload_too_large The data block exceeded 8192 bytes of JSON. Send a reference and let the surface fetch the rest over the API.
429 rate_limited You exceeded 60 messages a minute for this installation. The body carries retry_after in seconds, and the response carries a Retry-After header too.

The refusal text names the fix, not just the fault:

{"error":"payload_too_large","message":"The `data` object is 9008 bytes of JSON; the limit is 8192. Send a reference and let the surface fetch the rest over the API."}
{"error":"rate_limited","message":"This installation has published 60 messages in the last minute. Retry in 48s, or batch what you are sending into fewer messages.","retry_after":48}

The two limits, and why they sit where they do

8 KB on data, so the channel does not become a second data path: anything that needs data reads it from the API with its usual scope, which keeps scope enforcement on one door. Send "section A-3 was called", not the section roster.

60 messages a minute per installation — that is, per (school, app) pair. Not per app and not per school, because the pair is exactly the blast radius of a loop: a per-app limit would let a runaway loop in one school's copy silence your app in every other school, and a per-school limit would let one chatty app spend every other app's budget.

What is deliberately absent from the body

No tenant_id, no module. Tenant and module are read from the server-side installation context alone, and there is no place in the body to write them. Such a field would be an assertion, and honouring it would let an app installed in one school address another school's screens — the one thing the whole installation model exists to prevent. Send them if you like; they change nothing.

And no Idempotency-Key. That header replays a stored response for 24 hours so a write does not happen twice, and nothing is written here. An instant message served from a cache is a message that did not arrive while the caller is told it did — worse than a visible error.

A publish whose broadcast fails still returns 202 and logs a warning. The caller already did the real work — recorded the attendance, called the class — and the message only announces it; failing the request would undo something that happened for the sake of an announcement that did not.

Receiving — Okta.onMessage (contract 22)

Okta.onMessage((String event, Map data) {
  if (event == 'class.called') {
    setState(() { _section = '${data['section']}'; });
  }
});

One handler; a second call replaces the first. There is no handler list and no unsubscribe, and both absences are deliberate:

  • A handler list asks a question the sandbox cannot answer: which of them owns this message? One app, one channel, one handler.
  • Unsubscribing has nothing to do: the handler dies with the mounted mini-app, because the host opens the socket on mount and closes it on teardown. An unsubscribe symbol would add exactly one thing — a way for you to get the lifetime wrong and go silent while still on screen.

And there is no send symbol. Okta.post has sent since contract 1, and a second symbol for the same act is a second write path that drifts.

The socket belongs to the mounted app alone. Each target gets its own delegate, and each delegate its own socket and channel — so there is no shared socket for anything to leak through, and no app hears another's messages.

The channel name comes from the launch, not from the bundle. The bundle is reused for as long as payload_version holds, so a channel carried on it would be the channel of whichever tenant opened it before the school was switched — a socket that authenticates cleanly and delivers another school's messages. The launch is re-asked on every open, and the host takes the channel from there. You never touch any of this: you call onMessage and nothing else.

Listening needs no scope — the installation is the permission. A scope is what a school grants an app so it can reach the school's data; this channel carries only what your app itself sent to its own surfaces, in the school that installed it. What must hold is that the installation is live and active, and that is re-checked on every subscribe: uninstalling or suspending closes the channel. That is why there is no realtime.messages.read and never will be — a grant that is requested and buys nothing is a grant partners think they need.

The rule that saves your app: never wait for a message in order to draw

A dead socket changes nothing on screen. The realtime block on the launch payload is optional, and its absence is a normal state, not a fault: no broadcaster deployed, a server older than the feature, a platform where the socket does not open. Every failure after that is swallowed and logged: refused auth, dead host, malformed frame, a handler that throws.

And from inside the sandbox, a host that never calls your handler and a silent channel are the same thing — you have no way to tell them apart. So there is one rule:

Read state with Okta.get, then let messages update it. An app that draws an empty screen and waits for its first message shows that emptiness forever on any device outside coverage — which is exactly what a television in a school with a flaky network looks like.

A socket that never connects costs you freshness and nothing else. And the host retries for you: backoff starting at 2 seconds, doubling to a ceiling of 5 minutes, with ±20% jitter, and the counter resets on a successful subscribe — not on a successful connect.

Do not throw inside the handler. It is interpreted code invoked from a socket callback — outside any build, so there is no error boundary beneath it. The host wraps it twice as a backstop, but keep the body defensive: read from data carefully, and assume no key.

And as the publisher: emit every field, null when empty. A key merely missing from the decoded map is a raw null in dart_eval — is throws on it, and even OktaJson.str throws. Keep your payload a fixed shape and fill the gaps with an explicit null. You are both ends here, so the contract is between you and yourself.

A worked example: "class roll call"

One app, two surfaces: a teacher calls a section from their phone, and the classroom screen updates with no polling.

The phone surface — publishes after doing the real work:

Future<void> callSection(String section) async {
  // 1) The real work first: it is recorded on your own server.
  final res = await Okta.post('/api/roll-call/calls', {'section': section});
  if (res.status < 200 || res.status >= 300) {
    Okta.toast('Could not record the call');
    return;
  }

  // 2) Then the announcement. Its failure does not undo (1).
  await Okta.post('/api/apps/realtime/publish', {
    'event': 'class.called',
    'data': {'section': section, 'called_at': '${DateTime.now()}'},
  });

  Okta.playSound('success');
}

The screen surface — draws from state, then messages update it:

class Board extends StatefulWidget {
  const Board({super.key});
  @override
  State<Board> createState() => _BoardState();
}

class _BoardState extends State<Board> {
  String _section = '';

  @override
  void initState() {
    super.initState();

    // First: draw what is true now. This is the screen on a device
    // whose socket never connected — and it is a correct screen.
    _loadCurrent();

    // Then: let the message update it. Nothing here gates the draw.
    Okta.onMessage((String event, Map data) {
      if (event != 'class.called') return;
      final dynamic v = data['section'];
      setState(() { _section = '$v'; });
    });
  }

  Future<void> _loadCurrent() async {
    final res = await Okta.get('/api/roll-call/calls/current');
    if (res.status != 200) return;
    final dynamic v = res.json()['section'];
    setState(() { _section = '$v'; });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        _section.isEmpty ? 'No call right now' : 'Called: $_section',
      ),
    );
  }
}

And in your manifest — on the screen package alone, if that is the receiver:

"mobile": {
  "minContract": 22,
  "screen": {
    "enabled": true,
    "title": "Class roll call",
    "places": [
      { "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" }
    ]
  }
}

Note what is not in the example: no polling loop, no Timer, no Future.delayed re-asking the question. And if the socket drops, the screen keeps whatever _loadCurrent() last read — stale, not blank.

The realtime block on the launch payload

The launch payload carries a realtime block (on the screen launch too) with the driver, key, host, port, channel name and auth endpoint. You do not use it — the host consumes it and opens the socket; it is mentioned here only because it explains why the channel comes from the launch and not from the bundle.

When the broadcaster is not configured the block arrives as null, not as a missing key: absence would force a client to conflate "server older than the feature" with "feature switched off" — two facts with two behaviours.

Audible announcements (audio & speech)

A student-call app announces a child's name when their guardian reaches the gate, and its settings offer the school four sounds: a gentle bell, the name spoken by a synthetic voice, a recording the school made, and a recording the guardian made. In a browser all four worked. On the two surfaces that matter — the display on the wall and the employee's phone — only the bell did, because the host's entire audio surface was Okta.playSound(name): a closed vocabulary of three system tones. Three of the four modes had no path at all.

Contract 23 opens that path with four calls.

The four, and two capabilities rather than one

Call What it does What false means
Future<bool> Okta.playAudio(String url) Play an audio file from a URL your own server serves Playback did not start: an invalid URL, a file that cannot be reached, or a speaker that will not respond
void Okta.stopAudio() Silence whatever is sounding — the file and the voice alike — (returns nothing, and cannot fail)
Future<bool> Okta.speak(String text) Speak a string in the device's voice Speech did not start: no engine, or no voice for the language asked for
Future<bool> Okta.canSpeak() Can this device speak? It cannot — and that is a sound answer, not a fault

Two capabilities, not one, because the modes are different things. The two recordings — the school's and the guardian's — need a file played (playAudio); only the synthetic voice needs speech (speak). So an app that offers recordings alone does not need a TTS engine to exist on the device at all, must not ask about one, and must not disable itself over its absence.

Ask canSpeak() before you offer "announce by name" — this is the point

Many Android TV boxes ship with no Arabic voice whatsoever. Without the probe your app picks the spoken mode, the wall goes silent, and nothing anywhere says why: no error on screen, no line in a log, and no visible difference between "this device has no voice" and "the call never arrived".

false is an ordinary state on a healthy device. It is not a fault, and not something to retry — asking again in two seconds answers false again until somebody installs a voice on that device. The correct response is to step down: the recording, then the bell.

Ask it in two places: when you draw your app's settings, so you never offer a school an option that cannot work on its screen; and at the moment of the announcement, so you never assume that what was true at configuration time is still true.

And the answer is not cached — not by the host, and not by you. A school that installs an Arabic voice on Tuesday uses it on Tuesday; a wall display is not restarted for months, so an answer cached at startup means an installed voice goes unused until the next holiday.

On Windows the host resolves the voice by listing the engine's languages (the per-language probe is Android-only) and returns the engine's own spelling of the tag. You do none of this: the part that concerns you is that canSpeak answers truthfully on both platforms.

Nothing throws — every answer is a bool

A dead speaker, a 404 on the recording, a missing engine: all false. None of the four raises an exception up to you.

That is deliberate, because the audience is a wall: on a display with nobody standing at it, an exception is a red screen nobody clears until the school day ends. So check the returned value, and do not wrap the call in a try.

playAudio takes a URL your own server serves

The file is fetched by the platform's audio player, never by the authenticated client: no bearer and no tenant header rides to a partner file host. The URL therefore reaches only what the device could have fetched anonymously, and the call hands back a bool, not bytes.

In practice:

  • Absolute http(s) only; file:/// and its friends are refused.
  • The file must be served without Okta authentication. A short-lived signed URL your own server issues is a sound answer; an endpoint expecting an Okta token is not — it will answer 401 and you will get false.
  • If the recording lives behind your app's own domain, expose it on a public path your server serves. Do not pass a relative /api/… path: this call is not Okta.get.

One voice in the hall

A second playAudio replaces whatever is sounding, and stopAudio() silences both the player and the voice. There are never two channels open at once.

There is one loudspeaker on a wall, and two names overlapping in a lobby is worse than one name arriving late. So if two calls land back to back, the later one is what is heard — and that is the behaviour you want by default.

true means "started", not "finished"

There is no completion signal on this surface. The Future resolves when the sound starts, so you cannot chain two calls today — "ring the bell, then speak the name" is not expressible, because await on playAudio does not wait for the clip to end.

A known limit, stated now rather than discovered as a queue of announcements trampling each other on a school wall. If you need a sequence, space it with a delay you choose, and know that it is an estimate.

And why false can arrive late

playAudio may answer false late when the server is unreachable: the player waits out its own preparation timeout.

This is a choice, not an oversight. A fast "no" followed by a delayed noise would make the wall play the fallback bell and the recording both — so answering quickly here would buy exactly what the section above prevents.

playSound is unchanged and is not replaced

The bell is a confirmation drawn from a closed, host-owned vocabulary (success · error · warning, each with a haptic); this surface is an announcement. Two different things, which is why the first was not removed.

And the bell is precisely what you fall back to when the device has no voice and the recording cannot be reached — it works on every device, on every contract since 5.

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: works on every device, and is the floor nothing falls through.
  Okta.playSound('success');
}

And in your settings, do not offer what cannot work:

class _SettingsState extends State<Settings> {
  bool _voiceAvailable = false;

  @override
  void initState() {
    super.initState();
    _probe();
  }

  Future<void> _probe() async {
    final bool can = await Okta.canSpeak();
    setState(() { _voiceAvailable = can; });
  }

  // ... and in build: show "announce by name" only while _voiceAvailable,
  // and in its place a line saying this device has no voice installed.
}

Wired to an instant message — the screen listens, then announces, and the name comes from the payload:

Okta.onMessage((String event, Map data) {
  if (event != 'guardian.arrived') return;
  final dynamic v = data['student_name'];
  setState(() { _name = '$v'; });
  announce('$v', _mode, _recordingUrl);
});

And the previous chapter's rule still holds: never wait for a message in order to draw. An audible announcement sits on top of state you read with Okta.get; it is not a substitute for it.

No manifest key, and no scope

These are host symbols — not manifest keys and not grants. There is nothing to add to manifest.json and no scope to request from the school in order to speak or play a file: the sound leaves the speaker of the device your app was opened on, and reads nobody's data.

The only thing that moves is minContract — so do not go hunting for a key to add.

Voice notes and file uploads

Everything your app could send to your server was typed or photographed. Okta.uploadFile(path) has existed since contract 1, but the host's picker accepts five extensions (pdf, jpg, jpeg, png, heic), and nothing on this surface recorded audio at all. A teacher who wants to record a sentence for a parent, a guardian who wants to answer a dismissal call by voice, a supervisor who wants to attach the incident report the school already has as a .docx — none of the three had a way.

Contract 24 opens both: a microphone, and a picker you can tell what you want.

Call What it does
Okta.canRecord() Can this device record at all? Ask before you draw a record button
Okta.recordStart() Start recording. false = the host refused (no mic, permission denied, or a take already running)
Okta.recordStop() End the take and get an opaque handle (String?) naming it. null = there was nothing to end
Okta.recordCancel() Throw the take away and release the microphone. void, always safe
Okta.playRecording(handle) Let the person hear what they recorded before it goes
Okta.uploadRecording(path, handle) Upload the take to an API path — the same map uploadFile returns
Okta.uploadFileOfKind(path, kind) Pick and upload, with kind deciding what the picker offers

All of them are contract 24, so declare "minContract": 24 if you call any of them. If you call none of them, do not raise it: bumping without need turns away devices your app was working on.

Record → listen → send

Three verbs, and the middle one is why there are six symbols and not three: a voice note nobody can hear before it leaves is a voice note nobody sends twice.

String? _handle;
bool _busy = false;

Future<void> start() async {
  if (!await Okta.canRecord()) {
    Okta.toast('No microphone is available on this device');
    return;
  }
  if (!await Okta.recordStart()) {
    Okta.toast('Could not start recording');
    return;
  }
  setState(() { _busy = true; });
}

Future<void> stop() async {
  final String? handle = await Okta.recordStop();
  setState(() { _busy = false; _handle = handle; });
}

Future<void> play() async {
  final String? h = _handle;
  if (h == null) return;
  await Okta.playRecording(h);          // the same output playAudio uses
}

Future<void> send() async {
  final String? h = _handle;
  if (h == null) return;
  final dynamic result = await Okta.uploadRecording('/api/apps/notes', h);
  if (result == null) return;           // cancelled — does not happen here
  final int status = result['status'];
  if (status >= 200 && status < 300) {
    setState(() { _handle = null; });   // sent, so it cannot be sent twice
    Okta.toast('Note sent');
  } else {
    Okta.toast('Could not send: ${result['error']}');
  }
}

canRecord() before the button — this is the point

A classroom display has a speaker and no microphone. And on a phone the microphone permission can be permanently denied in Settings, where nothing your app does will ever raise a prompt again.

Without the probe you draw a record button, the user presses it, and nothing happens: no prompt, no error, nothing in any log. That is exactly the failure this probe exists to prevent — the canSpeak() lesson, word for word.

And false is an ordinary answer about a working device, not a fault to report or retry: offer typing, or the file the user already has, instead.

It does not request the permission, so it is safe to call while building a screen. recordStart() is the call that may raise the prompt.

A handle names one take, and is never reused

recordStop() returns an opaque string naming a take the host is holding — not the take itself: the sandbox has no filesystem, and the bytes never cross the bridge. There is nothing in it to read or parse; hand it to playRecording and uploadRecording and nothing else.

It is retired the moment another take starts, or your app closes. A retired handle is refused by name rather than standing for whatever the host is holding now — and that is the difference worth the extra argument: a parent hears their own voice played back and a different family's recording is what reaches the school.

So keep the handle for as long as its screen, and clear it after a successful send.

One take at a time

recordStart() on a host that is already recording answers false rather than silently discarding what is being said into the phone. And playRecording goes through the same audio output playAudio uses (contract 23): starting one silences the other, and Okta.stopAudio() cuts whichever is sounding — so you never have to know which it was.

uploadFileOfKind — and the vocabulary is closed

Okta.uploadFile(path) has not changed and will not: its five extensions stay. Widening it would have changed, silently, what every already-published app is handed by its picker, with nothing in its code saying so.

So if you want more, say which more:

kind What the picker offers
image The device's images
audio Audio files
video Clips
document pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv, rtf, odt, ods
any Everything

The list is closed, because the host draws the picker: a word it does not know could only degrade quietly into something else on every device in every school. An unknown kind comes back as {'status': 0, 'error': 'unknown file kind "…"'} — and the simulator refuses it too, so your typo is caught in a browser rather than in a school.

final dynamic picked = await Okta.uploadFileOfKind('/api/apps/files', 'document');
if (picked == null) return;                   // the user cancelled
if (picked['status'] >= 400 || picked['status'] == 0) {
  Okta.toast('Upload failed: ${picked['error']}');
  return;
}

document is a list of extensions rather than "everything" on purpose: a picker that offers everything is how someone attaches a 400MB video to a leave request and then waits for it on school wifi. Ask for any if you mean it.

null means "the user cancelled" — a failure is something else

This is the distinction to write into your code:

  • null ← the user closed the picker. Nothing to do, nothing to show.
  • a map with status: 0 or 4xx/5xx and an error ← a failure. Show it.

And uploadRecording never returns null for a retired handle or a host with no microphone: those are error results, because nobody cancelled anything — and an app that reads one as a cancellation drops a voice note believing it sent it.

Nothing throws

No microphone, a denied permission, a closed picker, a retired handle, a network that died mid-upload — all false, null, or an error result. Do not write try around any of the seven; whoever reads "this can fail" writes one, and the exception does not happen here.

Where it does not work, said honestly

  • Web (the browser build and the simulator): canRecord() answers false. The recording package on the web hands back a blob rather than a file, so a take could not travel the same upload leg.
  • The simulator: refuses all seven, and its file picker answers a cancellation, exactly as uploadFile has since the beginning. A simulator that always says yes never once exercises the fallback path you are being asked to write, and the first place you would find it missing is a screen in front of a real person.
  • Display screens (okta-screen): refuse all seven — a television on a wall has no microphone and nobody standing at it to pick a file.

So try the recorded path on a real device, and write the fallback that works when canRecord() answers false.

No manifest key, and no scope

Exactly as contract 23: these are host symbols. There is nothing to add to manifest.json and no scope to request from the school — the microphone is the microphone of the device your app was opened on, and the file goes to an API path your usual scopes already govern. The only thing that moves is minContract.

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) Glass, in the Okta shell
The permissions sheet, banners host They appear over your screen without asking you
Your app's canvas you In the material via OktaGlass, or solid via OktaSurface
Your cards, rows and buttons you In OktaPalette colours

package:okta_glass — the material, ready made

import 'package:okta_glass/okta_glass.dart';

Scaffold(
  backgroundColor: const Color(0x00000000),
  appBar: OktaAppBar.build("Today's attendance", palette),
  body: OktaGlass.background(          // the field, behind the whole screen
    ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        OktaGlass.card(                // a card-weight sheet
          OktaList.row(OktaIcons.calendar(), 'First period', '7:30', palette),
        ),
      ],
    ),
  ),
)
Call Weight When
OktaGlass.card(child) blur 22 · radius 20 · elevated A card — what you want most of the time
OktaGlass.chrome(child) blur 30 · radius 26 Something that floats over content moving beneath it
OktaGlass.field(child) blur 18 · radius 16 · flat An input — it has to read as recessed
OktaGlass.surface(r, b, e, child) yours Only when you must
OktaGlass.background(child) The field behind your body

One field per screen, never one per card. The field is what makes every sheet agree about where the light comes from; a field per card gives each one its own sun.

Two layers maximum. A third loses the edges their contrast and everything turns to fog.

Why the host draws it and you do not

The bridge does not have the tools. Glass is five layers, and three of them do not cross dart_eval:

  • BackdropFilter and ImageFilter.blur are not in the bridge's vocabulary at all — no blur.
  • BoxDecoration carries color and boxShadow only; gradient is commented out of the bridge declaration, and an undeclared named argument is silently dropped. So the raked tint compiles cleanly and never reaches Flutter — you get a flat rectangle with not one error, and validate.dart does not catch it because nothing failed.
  • BoxDecoration(border: …) does not compile at all — no specular rim.

And that indirection is the feature, not the cost. The drawing lives in the host's binary, not in your bytecode. So when Okta restyles the material — its colour, its blur, its edge — the change reaches every published mini-app on the next host release: no rebuild, no new version, not one line you edit. A partner who hand-rolled the look from ClipRRect and ColoredBox is frozen at the day they shipped.

package:okta_glass needs min_contract: 16 or higher. Declare it in the manifest, or compilation fails on a device carrying an older host.

And a contract's features are only real once a host build carrying them reaches the user's device. Raising min_contract ahead of that does not make your app newer — it makes it refuse to run on every device in the field until the store update lands. Check the published host contract in the partner dashboard before you depend on it.

The palette — the single source of colour

Never write a colour literal. Theme.of(context).colorScheme does not cross the bridge, so the palette is your only route to the host's identity:

import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';

Widget main() => const HomeScreen();

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // Build it once at the root and pass it down. Do not rebuild it per widget.
    final palette = OktaPalette.of(Okta.isDark());

    return Scaffold(
      backgroundColor: palette.surface,
      appBar: OktaAppBar.build("Today's schedule", palette),
      body: /* … */,
    );
  }
}
Role Field Use it for
Brand palette.brand Primary buttons, the selected tab, the accent icon
Brand soft palette.brandSoft Touch ripple, hover wash
On brand palette.onBrand Text and icons on top of brand
Ground palette.surface The screen background — Scaffold.backgroundColor
Raised surface palette.surfaceRaised Cards and blocks sitting on the ground
Strong text palette.textStrong Headings and body
Muted text palette.textMuted Descriptions and secondary data
Border palette.border Hairlines

brand is not the same colour in both modes: #6D428F in light, #A57BBA in dark. Saturated brand purple over a near-black ground glows and hurts legibility. Read it from the palette; never hard-code it.

These values mirror OktaTokens in the mobile app, and a test there — okta_kit_palette_parity_test.dart — fails the build if the two copies drift. What you read here is what actually runs on the device.

Recipes that give you Okta's shape despite the bridge

The route to the host's look is not copying its widgets — it is matching its numbers with the tools that work.

A rounded card. Not BoxDecoration(borderRadius:) — it is silently dropped and paints square corners. Corners come from ClipRRect:

Widget card(OktaPalette palette, Widget child) {
  return ClipRRect(
    borderRadius: BorderRadius.circular(16),  // radiusXl — the card radius
    clipBehavior: Clip.antiAlias,
    child: ColoredBox(
      color: palette.surfaceRaised,
      child: Padding(
        padding: const EdgeInsets.all(16),    // space4
        child: child,
      ),
    ),
  );
}

A hairline. Not Border.all — it does not compile. A hairline is a box one pixel tall:

Container(height: 1.0, color: palette.border)

Elevation — and a trap worth knowing. boxShadow is supported, but a shadow and a rounded corner cannot coexist: rounding works only through ClipRRect, shadows only through BoxDecoration, and BoxDecoration carries no borderRadius. So a shadow behind a rounded card is drawn square and shows at every corner.

Either a flat rounded surface separated by its colour (OktaSurface.card does this), or OktaGlass.card, which gets blur, edge and shadow at once because the host draws it. A hand-rolled shadow is only ever right on a square-cornered box.

The primary button — built, not configured. ElevatedButton and TextButton do not accept style on this engine; the parameter is not declared in the bridge, so your colour is silently dropped and the button arrives in the default grey. The button that carries your identity is assembled:

Widget primaryButton(OktaPalette palette, String label, void Function() onTap) {
  return GestureDetector(
    onTap: () => onTap(),        // a closure, not a function reference
    child: ClipRRect(
      borderRadius: BorderRadius.circular(999),
      clipBehavior: Clip.antiAlias,
      child: ColoredBox(
        color: palette.brand,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
          child: Center(
            child: Text(
              label,
              style: TextStyle(
                color: palette.onBrand,
                fontSize: 14,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
        ),
      ),
    ),
  );
}

Use ElevatedButton for a secondary action whose colour does not matter, and this for the primary one.

Text alignment. Text does not accept textAlign here, and TextAlign is not a bridged type at all. Alignment comes from Center or Align around the text, not from inside it.

The numbers that make you look like us. Match them; do not invent others:

Value
Card radius 16 — capsule 999
Card padding 16, compact 12
Spacing rhythm multiples of 4: 4 · 8 · 12 · 16 · 24 · 32
Text sizes 12 faint · 14 secondary · 16 body · 18 section title · 20 screen title
Weights 400 body · 500 label · 600 heading — never 700
Motion 150 fast · 220 base · 320 slow

Motion. Use package:okta_motion — its easeOutCubic is the host's own curve, so your app does not feel faster or slower than the screen it opened from:

import 'package:okta_motion/okta_motion.dart';

OktaMotion.entrance(index * 40, 220, card(palette, row))

entrance(delayMs, ms, child) is the same stagger the home screen's cards enter with: 40ms between one row and the next. Cap the delay at ten rows — the tail of a long table must not arrive two seconds late.

Icons. OktaIcons.* only. Arbitrary Icons.* constants are not bridged, and worse, the app is built with --no-tree-shake-icons precisely because your code points are chosen at run time: an icon outside the set can reach the device as an empty box with no error at all.

Text direction

The host is Arabic-first. AlignmentDirectional is not bridged, so direction comes from Directionality and OktaText:

final locale = Okta.locale();

Directionality(
  textDirection: OktaText.isRtl(locale) ? TextDirection.rtl : TextDirection.ltr,
  child: /* … */,
)

Text(OktaText.pick(locale, 'جدول اليوم', "Today's schedule"))

And one rule everyone forgets: identifiers, paths and version numbers stay LTR whatever the interface language — wrap them in Directionality(textDirection: TextDirection.ltr) or 04:A2:9F flips on screen.

Dark mode is not optional

Okta.isDark() follows the system appearance on the user's device. An app that reads the palette once at its root gets both modes for free; an app that writes Color(0xFF...) once is right in one mode and wrong in the other — and that is the single most common reason a visual review is rejected.

Pre-publish checklist

  • No colour literal anywhere in the code — all of them from palette.*
  • Scaffold.backgroundColor = palette.surface
  • Cards on palette.surfaceRaised, corners from ClipRRect not BoxDecoration
  • No Border.all, no BoxDecoration(gradient:) — the first does not compile, the second is dropped in silence
  • No hand-built glass — use OktaGlass (and min_contract: 16)
  • No shadow behind a rounded corner — it draws square
  • No ElevatedButton(style:), no Text(textAlign:) — both are dropped in silence
  • Components from OktaButton/OktaSurface/OktaList/OktaStates, not hand-built
  • Icons from OktaIcons exclusively
  • Opened in both light and dark, and the text read in both
  • Identifiers and paths wrapped LTR
  • All three states handled explicitly: empty, loading, error

A ready prompt for your coding assistant

Paste it verbatim into Claude Code, Cursor or Codex before asking for a screen:

You are building a Dart mini-app that runs inside the Okta mobile app,
compiled on the device by dart_eval. Follow this literally:

Identity:
- Every colour from OktaPalette.of(Okta.isDark()) — no literal
  Color(0xFF..).
- Ground palette.surface, cards palette.surfaceRaised, text
  palette.textStrong / palette.textMuted, hairlines palette.border.
- Card radius 16, capsule 999, padding 16, spacing in multiples of 4.
- Text sizes 12/14/16/18/20, weights 400/500/600 only.
- Icons from OktaIcons only.
- Components from okta_kit, never hand-built: OktaButton.primary/secondary,
  OktaSurface.card/hairline/pill/iconTile, OktaList.row/sectionHeader,
  OktaStates.loading/empty/failure, and the numbers from OktaMetrics.
- Motion from package:okta_motion, durations 150/220/320.

Engine limits — every one of these breaks:
- BoxDecoration carries color and boxShadow only. borderRadius and
  gradient are silently dropped: round corners with ClipRRect + ColoredBox.
- BoxDecoration(border:) does not compile: a hairline is
  Container(height: 1.0).
- No BackdropFilter, no ImageFilter. The material comes from
  package:okta_glass: OktaGlass.card / chrome / field / background
  (needs min_contract 16).
- A shadow and a rounded corner cannot coexist — use OktaGlass.card to lift.
- Buttons: ElevatedButton and TextButton only, and **neither accepts style**:
  build the primary button from GestureDetector + ClipRRect +
  ColoredBox(palette.brand).
- Text does not accept textAlign: align with Center or Align around it.
- Layout: Row, Column, Expanded — and always pass flex:. No Wrap, no
  AlignmentDirectional.
- No nested loops at all — flatten hierarchical data into one list.
- Callbacks are closures: onPressed: () => f() not onPressed: f.
- Keep JSON dynamic end to end; never give the receiver a Map type.
- No State.mounted, and no Timer (but DateTime, Duration and Future.delayed
  exist — write a drain loop as a self-scheduling Future.delayed recursion
  with your own stop flag).
- 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.
- 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.
- Before any call whose contract is above 5: guard with
  Okta.contract() >= N or declare minContract. Calling above the contract
  does not fail — it corrupts the interpreter for the rest of the session.
- list.sort() with no comparator throws: always pass the comparator.

Direction: Arabic-first via Directionality and OktaText.isRtl; identifiers
and paths stay LTR.

Handle all three states explicitly: empty, loading, error. Check res.ok,
and distinguish status == 0 (never reached a server) from a failed server
response.

Run dart run tool/audit_bridge_usage.dart lib/main.dart before publishing — it catches silently dropped parameters and the three traps above before a user does.

The MCP server's component_catalog tool serves the same set live from the platform — if your assistant is connected to it, ask before assuming.

Example: an attendance-by-scan app (native) — from nothing to published

One practical chapter that builds a complete Dart mini-app: a supervisor stands at the gate, waves student cards past the phone, and every read is recorded as an attendance — and kept on the device when the network drops. A real example, because it is what actually gets built, and because it touches everything a native app needs: state, pages, permissions, scanning, the network, storage, the three states, and the identity.

Before you write a line: four limits that shape the design

These are not preferences. Each one changes what your app can be.

1. There is no text input. TextField is not in the bridged surface. A native app is a read-and-act app: it shows, it scans, it taps, it sends. Any screen whose whole point is that someone types into it — a registration form, free-text search, a comment — is not a native app; build it in webview or external mode. Discovering this two weeks into the work is the most expensive lesson on the platform, which is why it is the first line here.

2. There is no navigation. Navigator.of is declared in the bridge with no implementation and no static dispatch entry, so the NavigatorState can never be obtained from inside the sandbox, and there is no route stack there either. Two screens inside your app means one piece of state holding a page number — the full pattern is below.

3. No nested loops. dart_eval 0.8.5 throws a compile-time RangeError when a loop body contains another loop — directly, or through a function it calls. Flatten hierarchical data into one list, on the server where you can.

4. A shadow and a rounded corner cannot coexist. Rounding works only through ClipRRect, shadows only through BoxDecoration, and BoxDecoration carries no borderRadius. When a surface has to lift and be round, use OktaGlass.card — the host draws that one natively and it gets all three.

0 · Layout and manifest

okta_app/native/attendance/
├── pubspec.yaml            ← pins okta_miniapp — do not bump the ref yourself
├── analysis_options.yaml
├── lib/
│   └── main.dart           ← the entry point: Widget main()
└── tool/
    └── validate.dart       ← the same gate CI runs

And the manifest:

"mobile": {
  "supported": true,
  "mode": "native",
  "entry": "okta_app/native/attendance/lib/main.dart",
  "minContract": 16
}

minContract: 16 because we use package:okta_glass. Nothing else to declare: device access needs no manifest entry since the capability gate was removed.

1 · State, and the trap that costs a day

StatefulWidget and setState work. Two traps:

setState(() { _busy = true; });        // block body
setState(() => _busy = true);          // boxes twice and throws

An arrow closure returns its body's value, and an assignment is an expression — so the value is boxed once to write the field and again to return it, and the second box lands on something already boxed: type '$bool' is not a subtype of type 'bool'. Convert every site, not only the ones that crash: whether the double box lands turns on register allocation, not on anything visible in the source.

The second: there is no State.mounted — it is unbridged, and naming it fails to compile. Call setState directly.

2 · Internal pages — the pattern, without Navigator

A page is a number in state, the bar decides what it shows, and the back button goes back internally and then hands over to the host when the pages run out:

class _AttendanceState extends State<Attendance> {
  int _page = 0; // 0 = scan · 1 = today's log

  void _openLog() {
    setState(() { _page = 1; });
  }

  // One back button for every page. It goes back internally while there is
  // somewhere to go, and hands over to the host on the first page — which is
  // the only way out of a mini-app.
  void _back() {
    if (_page != 0) {
      setState(() { _page = 0; });
      return;
    }
    Okta.close();
    // Doing nothing is a valid host response to close (there may be nothing to
    // pop), so never rely on it to stop execution — return straight after.
    return;
  }

  AppBar _bar(OktaPalette palette) {
    if (_page == 0) {
      return OktaAppBar.build('Attendance', palette);
    }
    return OktaAppBar.withBack(
        "Today's log", palette, Okta.appIcon(22.0), () => _back());
  }
}

Do not put the icon in leading on the first page. Flutter inserts a back button only when leading == null, so OktaAppBar.buildWithIcon silently removes the only way out. withBack takes the slot back for the button and moves the icon into actions.

3 · Scan — and read what comes back

There is no permission call to make first: the platform's capability gate has been removed, so a scan is just a scan. What has NOT changed is that it can come back empty, and that is the part worth writing code for.

Future<void> _scan() async {
  final key = await Okta.scanNfc();
  if (key == null) {
    // null covers every ending that is not a card: the user cancelled, the
    // phone has no NFC, the OS permission dialog was declined. None of them is
    // an exception, and none of them should be announced as an error.
    return;
  }
  await _submit(key);
}

4 · Sending — and three endings, not two

OktaApiResponse carries status, body, error and the computed ok. And the difference everyone forgets: status == 0 means the call never reached a server at all. Showing "the server returned an error" then is a lie to the supervisor — the server said nothing.

Future<void> _submit(String key) async {
  final res = await Okta.post(
    '/api/attendance/reads',
    <String, dynamic>{'key': key},
  );

  if (res.ok) {
    Okta.playSound('success');
    // body is dynamic end to end — never give the receiver a Map type.
    final name = OktaJson.strOr(res.body, 'student_name', key);
    setState(() { _log.add('$name · present'); });
    return;
  }

  if (res.status == 0) {
    await _queue(key);
    return;
  }

  Okta.playSound('error');
  setState(() { _log.add('refused · ${res.status}'); });
}

5 · The local queue — and the silent failure

Future<void> _queue(String key) async {
  await Okta.storePut('queued:$key', key);
  Okta.playSound('warning');

  // Say it out loud. The local capture is confirmed before the network is
  // consulted — which is right, it is how a scan survives an outage — but it
  // means an upload that never arrived looks **exactly** like one that did.
  // Someone standing at a gate is looking at the student, not at a badge in a
  // header, so the difference is told with a sound, a message and a log line.
  Okta.toast('Saved on the device — no network. It will send when it returns.');
  setState(() { _log.add('$key · waiting for the network'); });
}

Okta.storeKeys() and Okta.storeDelete(key) drain the queue later.

Storage is not secure storage. Treat it as readable by anyone holding the device: it is for a capture queue, not for a token.

6 · The list and the three states

Widget _logPage(OktaPalette palette) {
  if (_loading) {
    return OktaStates.loading(palette);
  }
  if (_log.isEmpty) {
    return OktaStates.empty(
      OktaIcons.list(), 'No reads yet', 'Wave a card to start.', palette);
  }

  // One loop, and a row builder with no loop inside it. A loop within a loop —
  // directly or through a function it calls — fails compilation with RangeError.
  final rows = <Widget>[];
  for (final entry in _log) {
    rows.add(OktaGlass.card(
      OktaList.row(OktaIcons.check(), entry, 'Today', palette),
    ));
  }

  return ListView(
    padding: const EdgeInsets.all(OktaMetrics.space4),
    children: rows,
  );
}

7 · Identity — without one colour literal

final palette = OktaPalette.of(Okta.isDark());

Scaffold(
  backgroundColor: const Color(0x00000000),
  appBar: _bar(palette),
  body: OktaGlass.background(          // one field for the whole screen
    _page == 0 ? _scanPage(palette) : _logPage(palette),
  ),
)

See "Okta's identity inside a mini-app" above for the detail. The rule here is one line: every colour from palette, every number from OktaMetrics, every component from okta_kit — so when Okta restyles the material your app changes without you publishing anything.

8 · Validate and publish

flutter test tool/validate.dart                       # the same gate CI runs
dart run tool/audit_bridge_usage.dart lib/main.dart   # catches the silently dropped

flutter analyze will complain that Okta and OktaPalette are undefined — that is expected. The libraries are injected by the engine at compile time and are not real pub packages.

Then create a new version. The on-device bytecode cache is keyed by (slug, entry, payloadVersion, runtimeSignature) and never by the content of the bundle: reinstalling without moving any of those keeps the old copy, with no message and no warning — you open the app, see the old behaviour, and conclude your fix did not work, when it never reached the device at all. See "The mini-app is cached" above for what each part means.

The whole file

import 'package:flutter/material.dart';
import 'package:okta_glass/okta_glass.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';

Widget main() => const Attendance();

class Attendance extends StatefulWidget {
  const Attendance({super.key});

  @override
  State<Attendance> createState() => _AttendanceState();
}

class _AttendanceState extends State<Attendance> {
  int _page = 0;
  bool _busy = false;
  final List<String> _log = <String>[];

  void _openLog() {
    setState(() { _page = 1; });
  }

  void _back() {
    if (_page != 0) {
      setState(() { _page = 0; });
      return;
    }
    Okta.close();
    return;
  }

  Future<void> _scan() async {
    if (_busy) {
      return;
    }
    setState(() { _busy = true; });

    final key = await Okta.scanNfc();
    if (key == null) {
      setState(() { _busy = false; });
      return;
    }

    final res = await Okta.post(
      '/api/attendance/reads',
      <String, dynamic>{'key': key},
    );

    if (res.ok) {
      Okta.playSound('success');
      final name = OktaJson.strOr(res.body, 'student_name', key);
      setState(() {
        _log.add('$name · present');
        _busy = false;
      });
      return;
    }

    if (res.status == 0) {
      await Okta.storePut('queued:$key', key);
      Okta.playSound('warning');
      Okta.toast('Saved on the device — no network. It will send when it returns.');
      setState(() {
        _log.add('$key · waiting for the network');
        _busy = false;
      });
      return;
    }

    Okta.playSound('error');
    setState(() {
      _log.add('refused · ${res.status}');
      _busy = false;
    });
  }

  AppBar _bar(OktaPalette palette) {
    if (_page == 0) {
      return OktaAppBar.build('Attendance', palette);
    }
    return OktaAppBar.withBack(
        "Today's log", palette, Okta.appIcon(22.0), () => _back());
  }

  Widget _scanPage(OktaPalette palette) {
    return ListView(
      padding: const EdgeInsets.all(OktaMetrics.space4),
      children: <Widget>[
        OktaGlass.card(
          Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Text(
                'Hold the student card against the phone',
                style: TextStyle(
                  color: palette.textStrong,
                  fontSize: OktaMetrics.textBody,
                  fontWeight: FontWeight.w600,
                ),
              ),
              const SizedBox(height: OktaMetrics.space4),
              _busy
                  ? OktaButton.disabled('Scanning…', palette)
                  : OktaButton.primary('Start scanning', () => _scan(), palette),
            ],
          ),
        ),
        const SizedBox(height: OktaMetrics.space3),
        OktaSurface.cardTappable(
          palette,
          () => _openLog(),
          OktaList.rowWithTrailing(
            OktaIcons.list(),
            "Today's log",
            'Everything recorded since the day began',
            OktaSurface.pill('${_log.length}', palette.brandSoft, palette.brand),
            palette,
          ),
        ),
      ],
    );
  }

  Widget _logPage(OktaPalette palette) {
    if (_log.isEmpty) {
      return OktaStates.empty(
          OktaIcons.list(), 'No reads yet', 'Wave a card to start.', palette);
    }

    final rows = <Widget>[];
    for (final entry in _log) {
      rows.add(Padding(
        padding: const EdgeInsets.only(bottom: OktaMetrics.space2),
        child: OktaGlass.card(
          OktaList.row(OktaIcons.check(), entry, 'Today', palette),
        ),
      ));
    }

    return ListView(
      padding: const EdgeInsets.all(OktaMetrics.space4),
      children: rows,
    );
  }

  @override
  Widget build(BuildContext context) {
    final palette = OktaPalette.of(Okta.isDark());

    return Directionality(
      textDirection: OktaText.isRtl(Okta.locale())
          ? TextDirection.rtl
          : TextDirection.ltr,
      child: Scaffold(
        backgroundColor: const Color(0x00000000),
        appBar: _bar(palette),
        body: OktaGlass.background(
          _page == 0 ? _scanPage(palette) : _logPage(palette),
        ),
      ),
    );
  }
}

Example: Daycare Operations app (External)

A self-contained worked example showing how to build an External app that targets daycare center tenants (daycare_center) — the new tenant type added to the Okta platform for leaf operating entities.

Why External and not Embedded?

Daycare centers need operationally-specific features: attendance check-in/check-out, daily reports pushed to guardians, and authorized pickup management. These are short-lived (daily) records that typically live in the partner's own store and integrate with third-party messaging or biometric hardware. External is the right fit because:

Reason Detail
Operational data stays with the partner Attendance logs, daily reports, and pickup lists live in the partner's store, not in okta-web.
Independent stack The partner may use biometric readers, RFID scanners, or existing mobile apps — none of which can be shipped inside okta-web.
Real-time guardian notifications The partner's own messaging channels (WhatsApp, Push, SMS) are already configured on their infrastructure.
Full stack freedom Any language or framework, without Embedded isolation constraints.

Attendance records, daily reports, and pickup data are not stored in okta-web and are not defined as partner scopes — they are operational data owned entirely by the partner app. Scopes are used only to read the okta-web data needed for correlation (student roster, guardian contacts).

Tenant type daycare_center

The platform has added the daycare_center tenant type to the shared canonicalTenantTypes catalog, which is pushed from okta-web to okta-partners over the bridge. This means:

  • The type appears automatically in the partner portal — no action needed on your side.
  • You can declare daycare_center as a primary target tenant type when creating your app.
  • daycare_center tenants can install your app and approve the requested scopes, just like any other tenant type.

No additional code is required to handle this type — once your app is published, the platform surfaces it to eligible tenants.

Required scopes

A daycare operations app needs to read student data from okta-web to correlate it with its own operational records. Pick scopes from the scope picker in the partner portal — the catalog is a mirror of okta-web, kept in sync automatically in the partner_available_scopes table. Do not hard-code scope names in your code; what appears in the picker is the authoritative source.

Scope Why
education.students.read Read the enrolled children for each tenant and match them with local attendance records.
education.students.write Optional — only if the app needs to write back a custom field (e.g. an RFID card number). Request it only when needed.

Least-privilege principle: start with education.students.read only. If you later need to write, add education.students.write in a new version with a clear reason in the manifest.

There is no attendance or daily-report scope because that data lives in your app's own store, not in okta-web.

Manifest

{
  "moduleId": "daycare-ops",
  "displayName": "Daycare Operations",
  "version": "1.0.0",
  "category": "operations",
  "integrationType": "external",
  "description": "Attendance check-in/out, daily guardian reports, and authorized pickup management for daycare centers.",
  "scopes": [
    {
      "key": "education.students.read",
      "required": true,
      "reason": "Match tenant children with attendance and pickup records"
    }
  ],
  "external": {
    "webhookUrl":    "https://daycare.example/okta/webhook",
    "webhookEvents": [
      "education.students.created",
      "education.students.updated",
      "partner.installation.token_rotated"
    ],
    "redirectUrls":  ["https://daycare.example/oauth/callback"]
  }
}

redirectUrls is useful if you want an OAuth-style flow after installation — redirect the tenant to /oauth/callback, receive the installation token, and run an initial roster sync.

Staying in sync via webhooks

After installation you receive subscribed events automatically. Verify the signature on every webhook (see Webhooks):

// Example: handling a student-update event
$event = $request->json('event'); // "education.students.updated"
$data  = $request->json('data.student');

match ($event) {
    'education.students.created' => $this->syncNewStudent($data),
    'education.students.updated' => $this->updateStudentRecord($data),
    'partner.installation.token_rotated' => $this->storeNewToken(
        $request->json('data.new_token')
    ),
    default => null,
};

return response()->json(['ok' => true]);

Respond 2xx within 10 seconds. For longer processing, return 202 immediately and handle via a background queue.

Recommended events for a daycare operations app:

Event When it fires
education.students.created A new child is added to the tenant
education.students.updated A student's data changes (name, contact, ...)
partner.installation.token_rotated The tenant rotated the token — store the new one immediately

Event names follow the canonical <feature>.<resource>.<action> format; do not invent custom names.

Initial sync after installation

On first install, page through the full student roster to seed your local store:

# First page
GET /api/apps/education/students?page=1&per_page=100
Authorization: Bearer <installation_token>

# Repeat until last_page is reached

After the initial sync, rely on webhooks for incremental changes. This minimises API calls and keeps your data real-time.

Guardian notifications

Daycare-specific notifications (arrival, departure, daily report) go out through the partner's own channels (WhatsApp / SMS / Push) — they are not part of the Okta notifications system.

If you later want to unify notifications with the Okta platform so tenants can pick channels from their dashboard, you can declare a notifications catalog for your app — see the Notifications catalog section.

Step-by-step summary

  1. Create an External app in the portal; declare daycare_center as a target tenant type.
  2. Request education.students.read from the scope picker (add write only if needed).
  3. Subscribe to education.students.created, education.students.updated, and partner.installation.token_rotated.
  4. Add a redirect_url if you want an OAuth-style initial-sync flow.
  5. On install: fetch the full roster via pagination, then rely on webhooks for ongoing changes.
  6. Submit for review — the Okta team checks the manifest and webhook URL, then publishes the app to the marketplace.

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.

What if a webhook is delayed?

Nothing is lost. Every delivery shows up on the "Webhook Deliveries" page with manual replay.

Where do I add my app's notifications?

See the Notifications catalog section. Short: Apps → pick your app → "Notifications" tab (/dashboard/modules/<slug>?tab=notifications).


More resources


↧ Download this page as Markdown · API reference →