Getting started
Okta Partner App Developer Guide
Aimed at developers building an app that integrates with the Okta platform. Assumes basic familiarity with HTTP/JSON and a server framework of your choice.
Table of contents
- Integration types
- Quick start
- Scopes
- Platform accounts: who opens your app
- App lifecycle
- Embedded apps
- Your app's database (migrations)
- Extending the student profile
- Landing widgets
- External apps
- Notification apps
- Payment apps
- Browser tools
- Manifest
- API
- Webhooks
- Notifications catalog
- Security
- Local testing
- AI-assisted development (MCP)
- Design system & UI
- AI support
- Okta mobile app
- Instant messages (realtime)
- Audible announcements (audio & speech)
- Voice notes and file uploads
- Okta's identity inside a mini-app (Liquid Glass)
- Example: an attendance-by-scan app (native)
- Example: Daycare Operations app (External)
- FAQ
Integration types
When you create a new app the very first thing you'll choose is the integration type. Everything else flows from this decision.
Embedded
Your app is shipped into okta-web as code and runs in the same process.
| Hosted by | Okta |
| UI | Livewire/Blade rendered inside the tenant dashboard |
| Communication | In-process calls into App\Services\PartnerApi\* |
| Repository | Dedicated GitHub repo with ready-made boilerplate |
| Pro | Seamless tenant UX, low latency |
| Con | Strict isolation rules — must go through PartnerApi |
External
Your app is hosted by you and talks to Okta exclusively over HTTP.
| Hosted by | You |
| UI | Yours (web/mobile/server-only) |
| Communication | REST API + signed webhooks |
| Repository | None — no source code lives at Okta |
| Pro | Full freedom over your stack |
| Con | You manage hosting, secrets, webhook security |
Notification (provider)
A pluggable notification provider (WhatsApp / SMS / Push / Slack / ...)
that the platform consumes through one unified send(recipient, message)
interface, regardless of the underlying transport.
| Hosted by | You (api), Okta (embedded), or both (hybrid) |
| UI | None — server-only app invoked by the rest of the platform |
| Communication | Single unified call from the platform per recipient |
| Pro | Adds a new channel (e.g. WhatsApp Cloud, Twilio) without core changes |
| Con | The platform owns the contract — you can't extend it on your own |
Three delivery modes:
api— OktaPOSTs to your endpoint with HMAC.embedded— you ship a class inside okta-web that implementsPartnerNotificationProvider.hybrid— embedded as the primary, falls back to api on failure.
Rule of thumb: Pick Embedded for features that should feel like part of Okta (e.g. a custom reports tab). Pick External when you need an independent service (e.g. linking Okta to a system you already operate). Pick Notification when you only want to add a delivery channel — no UI, no workflow, just a way for the rest of the platform to reach users on a new medium.
Payment (provider)
A payment gateway/method (Tabby / Tamara / Noon Payments / ...) a tenant
installs once, after which any other app charges through it via a uniform
charge(...) contract. Shaped like a notification provider, but the verb is
collecting money rather than sending messages. Same three delivery modes:
api / embedded / hybrid. Consuming this contract (charging from
another app) is available exclusively to Embedded apps — see
Consuming payments from your
app. Full details in
Payment apps.
Quick start
1. Create a partner account
Sign up through the partner portal and complete your company profile. You'll receive a tenant of your own that hosts every app you build.
2. Create your first app
From Apps → New app:
- Pick the integration type.
- Fill in basics (name, description, category, icon).
- For Embedded: connect GitHub so we can provision the repo.
- For External: enter
webhook_urland the events you want. - Pick the scopes you need from the catalog.
3. Submit for review
Hit Submit for review when the version is ready. Our team checks the manifest, runs the policy scanner against your repo (Embedded), and probes your webhook URL (External). Reviews typically come back in 1-3 business days.
4. Approved
The app publishes automatically to the marketplace. Tenants can install it, approve the scopes you requested, and start using it.
Scopes
Every piece of data in Okta is gated by a scope of the form:
<feature>.<resource>.<action>
Examples:
education.students.read → list/show students
education.students.write → add/update students (no delete)
education.face_embeddings.read → face templates (dangerous, read-only)
employees.directory.read → list/show employees
reports.builder.read → run curated reports
reports.builder.write → propose letterheads (not reports)
Rules:
- lowercase + dot-separated with no wildcards.
- Partners only ever get
readorwrite. Delete is never granted to partners. - The catalog is the single source of truth. You can't invent new scopes.
- Okta adds new scopes through platform updates.
Pull the live catalog:
GET /api/partners/permissions/catalog
For the full per-resource scope reference (endpoints + PHP/HTTP
examples), see commit a164484 on prod — the content is unchanged.
education.face_embeddings.read — face templates
A dangerous scope. The install screen paints it in the danger colours and the tenant admin will stop and read it. Request it only if your app genuinely matches faces, and write a
reasona head teacher can understand.
GET /api/apps/education/face-embeddings?model=AdaFace1&page=1&per_page=50
Returns the tenant's enrolled face templates as raw numeric vectors, so your app can match them on a machine in the building.
There is no .write tier and there never will be. Enrolment happens in
the platform with the person standing at a desk; an app able to write a
template could make any camera recognise anybody as anybody.
Why it is separate from education.student_photos.read. They look like
the same consent and are not. A photograph is what the school already prints
on a card and shows at a gate. A template is a key that matches this child
against any camera anywhere — and unlike a password it cannot be reissued
after a leak. A tenant that agreed to show faces has said nothing about
handing over the mathematics that recognises them.
Four things to know before you integrate:
modelis required and has no default.AdaFace1andArcFace_50r1produce vectors that are not comparable with each other. If the platform picked one for you, you would receive templates your engine silently fails to match — with no error anywhere.countis nottotal.countis what this page carries after released students and undecryptable rows drop out;totalis what the tenant holds. Page ontotal— deriving it fromcountstops your loop early on the first page that happens to contain a released student.- No names come back. You get vectors keyed by ULID. If you need to know
whose face you matched, hold
education.students.readas well and resolve the ULID there. One grant does not quietly deliver two. - Matching happens on your side, not the platform's. The vectors are handed over raw because the comparison has to happen where the camera is — a gate, in the building, usually offline. An endpoint returning similarity scores would require the network the gate does not have.
From a mini-app this needs no special symbol in okta_host — the host
allow-list already passes /api/apps/*:
final res = await Okta.get('/api/apps/education/face-embeddings?model=AdaFace1');
The organisation's clock — /api/apps/tenant/clock
No scope. Every installed app reaches it with its installation token alone, and nothing is asked of the organisation.
That is a decision rather than an omission: a timezone is not the organisation's data, it is the frame its data is read in — like the locale. Your app already receives that school's timestamps, so there is nothing here a grant could protect. Putting a scope in front of it would buy zero privacy and cost something real: apps rendering times in the wrong zone because nobody thought to request a grant nobody thought to give. A register shown an hour out does not look broken — which is what makes that failure expensive.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/apps/tenant/clock |
— | The wall clock the organisation reads its day by |
Response:
{
"timezone": "Asia/Riyadh",
"offset": "+03:00",
"now": "2026-09-02T14:31:07+03:00"
}
Store timezone, never offset
offset and now describe this instant, not this place. Only the name
carries the rules, so it stays right when the rules change under it.
This is not hypothetical — one supported country observes daylight saving:
| Zone | January | July |
|---|---|---|
Asia/Riyadh |
+03:00 | +03:00 |
Asia/Dubai |
+04:00 | +04:00 |
Africa/Cairo |
+02:00 | +03:00 |
An app that stored +02:00 for a Cairo school in January is a full hour
wrong in April — on every screen, with no error in any log, the date right,
the format right, and only the hour false. Had it stored "Africa/Cairo" it
would have moved with the country on its own.
Use offset to show beside a time, or to hand to something that cannot take a
zone name. Nothing else.
Storage stays UTC
Every timestamp the platform hands you, and every one you hand back, is UTC — and that does not change. This endpoint answers a different question: what to display. Convert on the way out, never on the way in; a value converted at write time is one nobody can compare across two schools afterwards, and the damage is invisible until two schools appear in the same report.
Where the value comes from
The school picks it in its own settings. If it has not, the value is derived from its country when that country has exactly one zone, and otherwise from the platform default. You always receive the final answer — so do not treat "no explicit choice" as a special case.
Embedded (PHP):
use App\Services\PartnerApi\Tenants\GetTenantClock;
$clock = app(GetTenantClock::class)();
$clock->timezone; // "Asia/Riyadh" — this is what you store
$clock->offset; // "+03:00" — display only
$clock->now; // ISO8601 on the school's clock
// And a stored UTC moment read on their clock:
app(GetTenantClock::class)->at($row->created_at)?->format('Y-m-d H:i');
From a mini-app (Dart) — needs no new symbol in okta_host and no
minContract bump:
final res = await Okta.get('/api/apps/tenant/clock');
if (res.status == 200) {
final zone = res.body['timezone']; // store this
}
Common uses: printing attendance times as the person who marked them saw them, deciding what "today" means for the school, scheduling a reminder for the local morning rather than the server's.
Do not ask once and store now. "What time is it" is a question you ask
when you need the answer; the name is the only part worth keeping.
Identifiers and core_reference
Every response leaving the platform replaces numeric IDs with ULIDs. A
core_reference is a column in your table holding one of them: a
26-character string pointing at a row in a platform table on okta-web
(students, subjects, grades, sections, tenants,
tenant_employees, users).
Why there is no real FK constraint: the referenced row lives in a
different database, on okta-web, and Postgres has no cross-database foreign
keys. So what you store is a plain char(26) / varchar(26) with no
constraint at all — and that is exactly the problem: char(26) is a width
you may legitimately use for anything else (a coupon code, an external
invoice number, a hash prefix). The width alone is not evidence, and
treating every char(26) as a platform reference would reject your
legitimate writes.
So the guard does not guess: you must annotate the column explicitly in the migration.
The annotation — two forms
Form A — a column comment in the Postgres catalog (preferred), because it survives into the live database where okta-web and the diff tooling can read it back:
CREATE TABLE "students" (
"id" BIGSERIAL PRIMARY KEY,
"owner_ulid" CHAR(26) NOT NULL,
"teacher_ulid" CHAR(26)
);
COMMENT ON COLUMN "students"."owner_ulid" IS 'core_reference:tenants';
COMMENT ON COLUMN "students"."teacher_ulid" IS 'core_reference:users';
In a Laravel migration that is ->comment():
$table->string('owner_ulid', 26)->index()->comment('core_reference:tenants');
Form B — a SQL comment on the column's own line, inside CREATE TABLE or
on a single-line ALTER TABLE … ADD COLUMN:
CREATE TABLE "students" (
"id" BIGSERIAL PRIMARY KEY,
"owner_ulid" CHAR(26) NOT NULL, -- core_reference: tenants
);
ALTER TABLE "students" ADD COLUMN "teacher_ulid" CHAR(26); -- core_reference: users
The separator is flexible: core_reference: tenants, core_reference=tenants
and core_reference(tenants) are all accepted. A bare core_reference with no
target still counts as an annotation (the ULID-format rule applies whichever
platform table is referenced) — but always name the target, since that is what
lands in the manifest.
What happens without an annotation
Nothing is rejected — that is the dangerous part. The column simply becomes invisible to the guard, so it is not protected:
App\Services\Partners\Sandbox\ValidateCoreReferenceWritesdoes not know the column is a platform reference, so any value you write into it passes without a ULID-format check and without checking the target table is allowed.- The manifest's
core_referencesblock comes out empty for your unannotated columns.
Unannotated 26-wide columns are reported separately as candidates and shown in the portal — a heads-up, not a rejection. The difference between "this table has no platform references" and "it may have ones we cannot see" is yours to settle, by annotating.
Path-based migrations (a
.phpfile living in your repo) carry no SQL that can be inspected at check time; they are counted asunreadableand reported as unchecked. Do not assume full coverage with them.
Forbidden (annotated or not):
(int) $row->student_id_ulid— casting it to an integer.where('student_id_ulid', 12345)— comparing it to a numeric literal.
Both are caught by the boilerplate scanner and fail CI. Always store and pass it as a string.
Platform accounts: who opens your app
Scopes answer "what data does your app read". This section answers something else entirely: who is sitting in front of the screen. The platform knows 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" }
]
kind—primary(the main audience) ordependent.- Exactly one target:
roles(roles inside the entity) orportal. - At least one surface:
web_routeand/ormobile_entry.
web_route is a Laravel route NAME — not a URL, not a /path — and it is a
namespace bound: school-app.admin covers school-app.admin.reports and
does NOT cover school-app.admins. The segment shape carries meaning; don't
write it casually.
The 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 custom — looks like it
works today, and stops working entirely the moment that value is flipped to
enforce.
So do not test your declaration against today's behaviour. Test it under enforcement:
config(['partners.audience_guard.mode' => 'enforce']);
And the rule there is deny by default inside a segmented app: the moment you
declare audiences, every route no audience covers is refused. A page you
forgot to declare does not stay open — it closes. Inventory your routes before
you declare your first audience.
Note: hiding the tile in the launcher works now, without waiting for the mode to flip, because it is display filtering rather than a guard. So you may well see your app hidden from a user who can still open its route by typing it — that gap is precisely the difference between
logandenforce.
App lifecycle
Draft → Submitted → In Review → Approved → Published
↓
Rejected
- Draft: edit freely.
- Submitted: in the review queue. Locked.
- In Review: our team is looking at it.
- Approved: green-lit. Will publish on next publish trigger.
- Published: live in the marketplace, installable by tenants.
- Rejected: returns to Draft with reviewer comments.
Embedded apps
Embedded apps live inside Okta but are forbidden from:
- ✗ Importing
App\Models\*(platform models) - ✗ Importing
App\Services\*other thanApp\Services\PartnerApi\* - ✗ Reading platform env keys (DB_, REDIS_, etc.)
- ✗ Reading
.envdirectly
Use PartnerApi services exclusively. Each service automatically
enforces its required scope. If the tenant hasn't granted your app
that scope you'll get a MissingScopeException (HTTP 403).
You get a Postgres schema entirely separate from Okta's
(m_<my_module>). PostgreSQL enforces the boundary at the role
level.
Full boilerplate layout, the right way to access data, and tables
guidance: see commit a164484 on prod.
Your app's database (migrations)
The migration files in your repo are the source of truth. There is no schema designer in the portal — there was one and it was removed, because it never wrote anything into your repo, so a drawn table became a third truth matching neither the repo nor the live database. You write an ordinary Laravel migration, push it, and import it.
The chain
database/migrations/ in your repo
│ "Import from repo" (or import_migrations_from_repo)
▼
the portal's migration store ──→ manifest.database.migrations[]
│
▼ provision_database (sandbox) / the portal button (production)
your app's own Postgres schema
Every link is required. Without the import, buildManifest() emits
requiresDatabase:false and migrations:[], and okta-web refuses to create
the database with "nothing to provision" — the import is also what flips
requires_database and fills database_schema on your versions.
File shape
database/migrations/2026_07_20_101500_create_exam_papers.php
└─────┬────────┘ └────────┬───────────┘
version migration name
YYYY_MM_DD_HHMMSS_<name>.php is mandatory. Anything that does not match
is skipped silently.
version is the key to everything after that: apply order, matching on
re-import, and the record of what ran. Re-timestamping an existing file
makes a NEW migration as far as the platform is concerned — it does not
edit the old one.
The baseline file
000000_create_<app>_database.phpThe boilerplate ships it starting with
000000deliberately: that name does not match the pattern above, so it is never imported. Creating the database itself is okta-web's step, and a file that does it again collides with it.But the skip is by name, not by intent. Rename it to a real timestamp (
2026_07_26_100000_create_<app>_database.php) and it becomes an ordinary migration: imported and shipped — usually with a timestamp older than your real migrations, which leads straight to the next error.
"Out-of-order migration" — and why it stops everything
Out-of-order migration "2026_07_26_100000" precedes already-applied
"2026_07_27_100001" on binding #21. Migrations are immutable once applied
— add a new corrective migration instead.
The applier walks migrations in version order and refuses to run one
older than the newest already applied to that database. The alternative
would be a lie: running an older migration after a newer one gives you a
schema no other install has ever passed through, and makes the apply order
differ between two databases claiming the same version.
The refusal aborts the whole run, not just that row. So one migration with a wrong timestamp freezes everything behind it: you read "7 applied, 1 pending" and nothing moves however often you press sync.
Getting out:
- If the row should never ship — the baseline file above being the textbook case — delete it from the list. It ran nowhere, so the deletion is completely clean and the block clears at once.
- If it is a real migration that arrived late with an old timestamp: give it a timestamp after the last applied one (a new file in your repo), then delete the old row and re-import.
- If it has already run somewhere, do not delete or edit it: add a new corrective migration. That is what "immutable once applied" in the error means.
Two statuses: draft and published
| Status | Where from | Editable | In the manifest |
|---|---|---|---|
published |
imported from the repo | ✗ SQL frozen | ✓ in every later manifest |
draft |
inline SQL typed in the portal | ✓ | ✗ until published |
The import writes published directly — these are real release files,
not drafts being iterated on. So "published" here does not mean somebody
decided to release it: a file that was incomplete the day you pushed it
arrives frozen.
Correcting a migration after it has arrived
Route one — a new migration. This is the normal one: an ALTER corrects
what its predecessor did. Published SQL is frozen for a reason — a school's
database has already run it, and rewriting it underneath makes what is in
that database differ from what the manifest claims.
Route two — delete. Available on every row, published or draft, from the database table in the portal. Know what it does and does not do:
- ✓ Removes it from every future manifest → new installs will not get it.
- ✗ Undoes nothing already applied. A table created last week stays created.
- ✗ Does not touch manifests already sent to okta-web; the difference starts at the next publish.
- ✗ Does not touch the applied ledger. That ledger says what a real database executed, and editing it to match a decision made afterwards would leave the platform unable to say what is in that schema.
So if the migration has run on existing databases, deleting creates a permanent divergence between them and any new install. The portal shows a "ran on N databases" badge on the row and changes the confirmation text accordingly — read it: it is the difference between tidying a list and two schemas that will never match again.
When is deleting the right answer? An incomplete or broken migration that has not run on any database, or a row whose file is no longer in your repo at all.
Rows whose file is gone
The import adds and updates; it never deletes. So if you delete a file from your repo, push, and re-import, you get "imported successfully" back while the deleted file is still shipping in every manifest.
That is why the import reports how many there are and badges those rows
"not in repo". They are not deleted automatically: the import runs
against one ref, a file absent from it may be absent only from that
branch, and it may already have run against a live database. The call is
yours, on the row.
Inline SQL in the portal
For small corrections that do not warrant a repo round-trip. Saved as
draft and published with the next version. Statements with no business in
an app migration are refused — DROP DATABASE/SCHEMA/ROLE/USER,
GRANT/REVOKE, ALTER ROLE/USER, COPY … FROM PROGRAM, and the
server-file functions. The real safety net is not that list but the isolated
schema: the connection role cannot touch another app's data at all.
MCP tools
| Tool | What it does |
|---|---|
list_migrations |
Migrations in apply order + the requires_database flag per version |
read_migration |
One migration's body — the copy the platform actually executes, which can drift from your repo |
import_migrations_from_repo |
The "Import from repo" button, and it reports missing rows |
provision_database |
Create the sandbox database and apply what has not been applied (production stays in the portal) |
database_status |
Whether the database is provisioned, the last run's outcome, applied vs pending |
diff_schema |
What your files describe against what is actually in the sandbox database |
diff_schema always states its own coverage: a migration stored as a repo
path carries no SQL here, so the checker cannot claim "undeclared" about
something it never read.
Extending the student profile
An embedded app can add content to okta-web's student-profile page straight from code — no portal forms. Write a Livewire component in a fixed folder, annotate it with a single attribute, and the platform auto-discovers and registers it.
Embedded apps only. External apps surface their data over HTTP/webhooks, not through this registry.
Folder layout (shipped in the boilerplate)
Modules/<App>/app/StudentProfile/
├── Panels/ ← full cards (Panel)
├── Stats/ ← header-strip numbers (Stat)
└── Actions/ ← header buttons (Action)
Any annotated component under these folders is discovered and registered automatically at boot — there is no registration code to write.
Declaration: an attribute on the class
namespace Modules\MyApp\app\StudentProfile\Panels;
use App\Support\StudentProfile\Attributes\StudentPanel;
use App\Support\StudentProfile\StudentProfileZone;
use Livewire\Component;
#[StudentPanel(
title: 'Attendance summary',
permission: 'my_app.records.view',
order: 50,
zone: StudentProfileZone::Main,
)]
class AttendanceSummary extends Component
{
public string $studentHashid; // the only identifier passed — never a numeric id
public function render()
{
return view('my-app::student-profile.attendance-summary');
}
}
keyandcomponentare derived automatically from the class.- Only
title/label+permissionare required; the rest is optional.
Mandatory rules
- Use
studentHashidonly — never a numeric id. - Every contribution declares a
permissionin the<feature>.<resource>.<action>shape. - This surface is embedded-only.
The four types (attribute parameters)
1) #[StudentPanel] — a full card
| Param | Required | Description |
|---|---|---|
title |
✓ | Card title |
permission |
✓ | View permission |
order |
— | Order (default 100, lower first) |
zone |
— | StudentProfileZone::Main (wide column) or ::Sidebar |
icon |
— | Icon |
description |
— | Short description |
key |
— | Override the derived key |
2) #[StudentStat] — a header-strip number
| Param | Required | Description |
|---|---|---|
label |
✓ | Label |
permission |
✓ | View permission |
order |
— | Order |
icon |
— | Icon |
color |
— | primary/success/warning/danger/… |
The component itself is the live widget that renders the value.
3) #[StudentAction] — a header button
| Param | Required | Description |
|---|---|---|
label |
✓ | Button text |
permission |
✓ | View permission |
url or event |
✓ | Open a URL or dispatch an event |
params |
— | Event params (with event only) |
order |
— | Order |
variant |
— | primary/secondary/danger/… |
icon |
— | Icon |
url takes precedence over event. The event is broadcast via Alpine
$dispatch and caught by Livewire #[On] or wire-elements
(openModal).
4) Zone
Main— the wide centre column.Sidebar— the side column.
The unified block chrome (<x-profile-block> / <x-profile-stat>)
The student profile is a unified block dashboard: every card renders inside
platform-owned chrome that shows its source (your app) with its accent
colour, a sync-status pill, last-synced time, and an "open in app" link. To
make your card blend into the board, wrap your panel content in
<x-profile-block> (instead of a bare <x-card>):
{{-- my-app::student-profile.attendance-summary --}}
<x-profile-block
:title="__('my-app::profile.attendance')"
:source="\App\Support\StudentProfile\BlockSource::resolve('my-app')"
status="live" {{-- live | syncing | offline | error --}}
:last-synced-at="$syncedAt"
:open-in-app-href="route('store.show', 'my-app')"
:open-in-app-label="__('my-app::profile.open')">
{{-- your content: chart / table / list — tenant data via App\Services\PartnerApi\* only --}}
<x-slot:actions>…</x-slot:actions> {{-- optional: ⋮ menu --}}
</x-profile-block>
BlockSource::resolve('<module-slug>')gives you a stable source identity (name / accent / icon) — don't invent a colour per card. (Or passsource-label/accent/icondirectly.)- Status is dynamic: pass
statusandlast-synced-atat render time from your real sync state.status="offline"shows an offline state — use<x-slot:offline>for the fallback (link-to-connect) content. - Stat tiles (
#[StudentStat]) use<x-profile-stat>(:label+:value+:source) so they appear in the stats row attributed to your app. - The platform owns the chrome; you supply content + status only. The "data sources" bar and the source filter are derived automatically from your registered blocks (no extra config).
How it shows in the portal
The partner portal does not run your code, so its Student Profile
tab is read-only: it lists the contributions okta-web discovered after
a sandbox install, and the published manifest's studentProfile block is
derived from them. You write the code — everything else is automatic.
Your component receives
studentHashidonly:<livewire:my-app::student-profile-summary :studentHashid="$studentHashid" />.
Landing widgets
An embedded app can contribute blocks to the tenant's public landing-page builder: your widget appears in the editor's "Your apps" category (only for tenants that installed your app), the tenant drags it onto their public site, and visitors see it like any platform block — without the tenant writing a single line of code. You declare the widget in the manifest and ship one Livewire component; the platform provides the rest:
- The editor card, carrying your app's name and icon.
- The block settings form, generated automatically from your
settings_schemadeclaration — you write no editor Blade at all. - Block behaviour (move / copy / columns / show-hide) comes from the
builder itself; the widget is stored inside the page document as one
bridge block of type
partner-widget— installing a new app adds no block class to the platform. - Security, free and mandatory: the runtime context on the public page is built by the platform, not by you.
Embedded apps only. A landing widget renders in-process inside okta-web as a Livewire component; an app hosted elsewhere has nothing to render there. Declaring
landing_widgetswith any otherintegrationTypefails the version publish.
Read the security model before you build. It decides what your widget can reach at all — people and money data will never reach your widget on a public page, whatever scopes your installation holds.
Declaring widgets in the manifest
{
"integrationType": "embedded",
// ... rest of the manifest ...
"landing_widgets": [
{
"key": "admission_register",
"label": "التسجيل في القبول",
"label_en": "Admission Registration",
"description": "نموذج تقديم طلب قبول من موقع الجهة العام.",
"description_en": "Admission application form on the tenant public site.",
"icon": "clipboard",
"category": "conversion",
"livewire_component": "my-app-landing-register",
"interaction": "static",
"scopes": ["education.grades.read"],
"cache_ttl": 300,
"settings_schema": {
"fields": [
{ "key": "title", "type": "text", "label": "العنوان", "label_en": "Title",
"localized": true, "default": "سجّل الآن", "min": 1, "max": 80 },
{ "key": "show_deadline", "type": "toggle", "label": "إظهار آخر موعد", "default": true }
]
},
"preview": { "thumbnail_url": "https://cdn.example.com/widgets/register.png" }
}
]
}
Fields
landing_widgets is an array of at most 10 widgets per app.
| Field | Required | Value |
|---|---|---|
key |
✓ | ^[a-z][a-z0-9_]{1,39}$ — unique within the app. The global identifier is the composite <module-slug>:<key>; stored blocks on tenant pages reference the widget by it. Changing it after publish = a new widget and every old block loses its reference — name it well in the first release. |
label / label_en |
label ✓ |
Card name in the editor (2–60 chars). label_en is used per locale and falls back to label. |
description / description_en |
— | Short text under the card (≤ 300 chars). |
icon |
— | Icon name (≤ 40 chars, default puzzle). An unknown value falls back to the default icon with no error. |
category |
— | The card's editor group: hero | content | media | conversion | layout (default conversion). |
livewire_component |
✓ | Livewire component alias, flat form only. An alias containing :: is refused at publish — Livewire 4 never resolves it anyway (the same trap as the App Control Panel). |
interaction |
— | static (default) or interactive. Interactive is a much heavier contract — see Interactive widgets. |
scopes |
— | Read scopes the render needs at runtime (≤ 20), each ending strictly in .read — any other verb fails the publish. This is a restriction, not a grant: what you declare here enters the read-leg intersection, and what you do not declare never reaches the widget even when the installation holds it. |
public_writes |
— | Write scopes the widget performs on behalf of an anonymous visitor (≤ 10), each ending strictly in .write. Three publish-time conditions: the shape; every entry being an active scope marked public_writable in the okta-web catalog (checked against the live catalog — and refused rather than accepted unchecked if the catalog cannot be reached); and interaction=interactive — a static widget is served from cache with no round trip in which a write could ever happen. |
cache_ttl |
— | Seconds the rendered HTML is cached on the public page (0–86400, default 300). Ignored entirely for interactive widgets — a cached body would replay one visitor's form state to another. |
settings_schema |
— | The declarative settings form — next section. |
preview.thumbnail_url |
— | Card thumbnail. Absolute https URL only. |
The settings form: settings_schema
{ "fields": [ { /* field */ }, ... ] } // at most 20 fields
The types are a closed vocabulary — exactly eight:
text, textarea, number, toggle, select, color, image, link.
There is no html type and there never will be (declaring it is
refused at publish with an explicit message). That is a security
decision, not a gap: every setting value passes through Blade escaping at
render time, so a tenant admin (or whoever compromises their account)
cannot inject script into the public page through your widget's
settings. If you need rich output, build it in your component from
structured fields.
Field properties:
| Property | Applies to | Description |
|---|---|---|
key |
all | ^[a-z][a-z0-9_]{0,39}$, unique within the widget. |
type |
all | One of the eight types. |
label / label_en |
all | Field title in the inspector (label required, ≤ 120 chars). |
localized |
all | true = the editor writes the value for the open locale only; false/absent = it mirrors the value to both locales. Your component always receives single-locale settings — the locale of the page being served — and never needs to know which fields are localized. |
default |
all | Initial value on insert. Checked against the type: a toggle accepts only a bool, a number only a number, and a select's default must be one of its declared options. |
help / help_en |
all | Help text under the field (≤ 300 chars). |
min / max |
text, textarea, number |
Length bounds for text, value bounds for numbers. On other types they are silently dropped. |
options |
select |
[{"value": "...", "label": "...", "label_en": "..."}] static list (≤ 50 options, unique values). A select without options fails the publish. |
options_endpoint |
select |
Reserved and currently refused. The shape (<provider>:<name>) is validated, then the declaration is explicitly refused at publish while no resolver exists on the platform — a readable publish error today beats a field that silently degrades into a text box in every tenant's editor. Use static options. |
An example covering all eight types:
{ "fields": [
{ "key": "title", "type": "text", "label": "العنوان", "localized": true,
"default": "سجّل الآن", "min": 1, "max": 80 },
{ "key": "intro", "type": "textarea", "label": "المقدمة", "localized": true, "max": 400 },
{ "key": "max_rows", "type": "number", "label": "عدد الصفوف", "default": 5, "min": 1, "max": 20 },
{ "key": "show_fees","type": "toggle", "label": "إظهار الرسوم", "default": false },
{ "key": "layout", "type": "select", "label": "التخطيط",
"options": [
{ "value": "grid", "label": "شبكة", "label_en": "Grid" },
{ "value": "list", "label": "قائمة", "label_en": "List" }
],
"default": "grid" },
{ "key": "accent", "type": "color", "label": "لون التمييز", "default": "#0ea5e9" },
{ "key": "banner", "type": "image", "label": "صورة الترويسة" },
{ "key": "policy", "type": "link", "label": "رابط سياسة القبول", "localized": true }
]}
Two points govern how storage works:
- The block is stored per locale inside the page document, and non-localized fields are duplicated across both locales with the same value — there is no "shared" store.
- A block inserted before you added a new field in a later release
will not carry its key. Therefore: every
settingsread in your component goes through?? $default, always.
Writing the component
The widget is an ordinary Livewire component inside your app, under the full Embedded code contract — being on a public page is no exemption:
- Data exclusively through
App\Services\PartnerApi\*— the policy scanner fails the build on anything else, and every service checks its scope internally, so whatever is not in the context is refused automatically. - Never assume a user. On the public page
auth()->user()is alwaysnull. Code that assumes a session breaks there even though it works in the editor preview. - Developer settings work:
GetAppSetting/GetAppSettingsrun inside the widget like any runtime context — reading your own app's store only, so you can change widget behaviour without publishing a new version. - UI to platform standards:
<x-…>components and semantic tokens — the widget renders inside the tenant's page with their theme and brand, and hard-coded colours break that. - Assume failure: an exception from your component on the public page shows no error page — it produces a silent void where the widget was (see below).
A static widget (static) renders once and its output is cached
for cache_ttl seconds: any wire:click or wire:model in a static
blade freezes into dead attributes in cached HTML. Right for display:
lists, stats, cards, links. Its component receives exactly
settings + locale at render time and nothing else — anything it
needs about the tenant it reads from the active ModuleContext like
every other PartnerApi service.
The security model (two asymmetric surfaces)
The two render surfaces are not symmetric, and the difference between them is the whole design:
- Editor preview: the tenant admin is signed in, so the context resolves the usual way (the same path as the rest of your app's pages). The preview sees what your app normally sees.
- Public page: the visitor is entirely anonymous — no user, no session. The platform rebuilds the runtime context per request from the tenant alone (resolved from the domain), and the context's scopes are the union of two sets, each an independent three-way intersection:
READ = (granted to the install) ∩ (catalog public_surface) ∩ (widget scopes)
WRITE = (granted to the install) ∩ (catalog public_writable) ∩ (widget public_writes)
context = READ ∪ WRITE
They are deliberately NOT one path with a merged flag: read and write
answer different questions ("may a stranger see this?" versus "may a
stranger cause this?"), and a single path would let a future edit to
one silently move the other. The verb filter runs twice on each
leg: a .write inside scopes[] is not a read declaration whatever
the manifest says, and a mis-marked catalog row cannot ride a grant
into the context.
The write leg carries two extra conditions: a platform-wide interactive flag checked inside the resolver itself, and a public write ticket that is only minted after the barriers pass (see Interactive widgets). No ticket = an empty write set — which is what every read path gets, by default, forever.
The governing principle: what never enters the context needs no guard. No new permission check, no extra middleware, no deny-list to maintain — a deliberately poor context, and the same existing guard that protects every PartnerApi service refuses everything outside it.
The practical consequence you must know before you build: student,
guardian, employee and financial scopes are never marked
public_surface or public_writable. Their exclusion is
structural, not policy: it is not a middleware rule someone could
miss — they simply never enter the context, so every call to them is
refused as if they were never granted. A widget calling ListStudents
on a public page will fail always, whatever you declare in
scopes. If your widget concept needs people data on a public page,
the concept itself needs rethinking.
Today's public_surface list (may grow by platform decision, very
conservatively):
countries.directory.read— public reference data (country list).countries.education_levels.read— the reference ladder of education stages.education.grades.read— the tenant's own grade levels (institutional structure a school already publishes on its site — no person and no record in it).
The scopes and public_writes fields in your declaration are the
third leg of their intersections, and they work in your favour: declare
the minimum, and a vulnerability in your widget's code can only reach
what you declared — even when your installation holds more.
Failure is a silent void — and cached
A widget failure on the public page — an exception, a refused scope, an alias that does not resolve, an uninstalled app — produces a silent void where the block was, and never takes the tenant's site down. A static widget's failure is also cached for 30 seconds to prevent hammering: a crashing widget is not re-executed for every visitor. Turn that around for development: your fix may not show up immediately because of the failure cache, and you will think "the widget just doesn't work" while the real cause is a swallowed exception — check the okta-web logs, and do not use repeated page refreshes as a test.
On the other surface — an interactive widget's round trip — failure is not silent: a request that cannot be attributed to a widget the tenant actually owns is refused with 403. Silence for the page, candour for the round trip.
Interactive widgets
A widget that accepts input from an anonymous visitor (a submission form, say). A much heavier contract than flipping the field, and every sentence of it is enforced by code, not by review.
The base class is mandatory — and CI enforces it
Your component must extend
App\Support\LandingWidgets\PublicWidgetComponent — a class the
platform owns. The landing-widget-base-class rule in the policy
scanner fails the build for any class under app/LandingWidgets/ in
your app that does not extend it: a plain Livewire\Component there is
not "a widget without the extras" — it is a public write path with no
context derivation and no barriers, failing open instead of closed. (An
intermediate base class of your own that extends PublicWidgetComponent
is a legitimate shape — opt it out on the declaration line with
// partner-policy:allow=landing-widget-base-class.)
Why: /livewire/update remembers nothing
The first paint is made by the platform while it holds the tenant, the
install and the context. But every wire:click after it goes to
/livewire/update — a route that knows no landing pages and no
tenants; all it has is the snapshot the browser sent. So the base class
re-derives the full context on every hydrate from live data: real
tenant → the plan still includes widgets → the interactive surface is
on → the widget is still in the tenant's current catalog → declared
interactive → context minted through the intersections. #[Locked]
on the identity properties does real work but is not the guard: a
forged tenantUlid resolves against live data and is refused because
that tenant has not installed your app. Even the settings carried on
the snapshot are re-clamped to the widget's current schema before any
use.
mount(), boot(), dehydrate() and exception() are all final
on purpose. Your hook is mounted(), which runs once after the
context is up. And never store the context on a property — per-request
values are remade on every hydrate.
At render time your component receives — in addition to settings —
the identity triple moduleSlug / widgetKey / tenantUlid plus
widgetLocale, because it will rebuild the context from them on every
round trip. (The choice is made by inspecting the resolved class, not
the declared interaction field.)
Writes: withPublicWrite() and the three barriers
Every write on a visitor's behalf goes exclusively through:
public function submit(): void
{
$this->validate([...]);
$this->withPublicWrite('submit', function (): void {
app(\App\Services\PartnerApi\...\CreateSomething::class)(...);
});
}
In order, all of them in the base class so you cannot decline one:
- honeypot — a hidden field the platform owns (see the partial below). Filling it = a bot.
- throttle — per (IP × tenant × module × widget × action); the
default is 5/minute, or
maxPerMinuteper call.throttle()is also available to you standalone, to protect an expensive read. - captcha — only active when the platform has a provider bound and the tenant switched it on (or the platform forces it globally). The platform ships no provider by default, so the barrier is absent rather than blocking a tenant with a check that cannot run.
A refusal is not an exception you handle: the callback simply does
not run, withPublicWrite returns null, and $widgetBarrierMessage
carries a translated sentence the partial shows the visitor.
After all three pass — and only there — the public write ticket is
minted, and the context is re-derived with it so the write leg
enters; the callback runs; then a finally re-derives without the
ticket — the write clearance lasts exactly as long as the write,
and the following render runs with a read-only context. Nowhere else in
the platform mints the ticket, so there is no path to a public write
that skipped a barrier.
The form-guard partial — a mandatory include
@include('landing-builder::blocks.partner-widget.partials.form-guard')
Once, inside your component's root element. It renders the honeypot,
the guard marker and the refusal message. Enforcement comes from the
HTML actually rendered: the base class inspects its own render output
and derives the guard state from it — a form without the partial
arrives at the next write with a permanently failing honeypot barrier,
and there is no way around it. The misleading symptom when you forget
it: the form shows and appears to work, and every write returns null
with a barrier message.
The deployment trap: SESSION_DOMAIN — know it before you ship
An interactive widget = a POST with a session and CSRF, and the cookie only returns to the server if it covers the served host's domain:
SESSION_DOMAINunset: every tenant domain (custom or subdomain) gets its own session. This is the arrangement that works.SESSION_DOMAINpinned to the platform domain: a tenant serving its page fromexample.comgets no session cookie at all — the page renders, the form shows, and the first click is a 419 in front of a visitor who cannot report it.
The platform detects the second case rather than "fixing" it (a platform-wide cookie domain is usually set deliberately): it suppresses the interactive widget on that host and shows a polite card instead of a form that ends in 419, with a warning line in the okta-web logs. Not your bug and not your code's — a deployment precondition. Test on a custom domain if your tenants use them.
The only public_writable scope today
education.admission_applications.write — an admission application
submitted by a visitor on the tenant's own public site. A mailbox:
things go in and never come out — it has no .read counterpart at
all (a public read would expose other applicants' submissions to any
visitor), and it is marked is_dangerous, so the install-grant screen
paints it in danger colours on purpose.
Extending the list is a platform decision, never requested through the manifest, and governed by three non-negotiable rules: intake only (creating a new inbound record about the visitor themselves — no update, no touching an existing record), no people, no money, and never a delete. Have a use case? Bring it to the platform team.
Lifecycle
- Publish: you add
landing_widgetsto your version's manifest and publish it. Validation happens on acceptance (shapes, limits,integrationType=embedded,public_writesmarkings against the live catalog) — a non-conforming block refuses the version. - Install: the tenant installs your app from the store and grants scopes as usual.
- Appearing in the editor: the tenant's widget catalog is derived per request from its installations — installed apps' widgets only.
- Insert: the tenant admin drags the widget in, and the inspector
shows the settings form generated from
settings_schema. The preview renders with the admin's context. - Page publish: on the public site the widget renders with the
anonymous context (the union of the two intersections). A static
widget's output is cached for
cache_ttlseconds (the cache key carries a settings fingerprint — editing the settings and republishing shows immediately); an interactive widget renders live on every request. - Uninstall: the widget disappears from the editor catalog immediately. Blocks already placed on published pages lose their reference and fall into the same failure path: a silent void, no error on the tenant's site. (The tenant deletes the orphan block from its editor whenever it wants.)
- Version update: the catalog reflects the installed version's
manifest. A new settings field shows up in old blocks only through
your code's default (
?? $default); deleting a widget or changing itskeyorphans old blocks exactly like an uninstall.
Registering the alias and the component
// In your app's ServiceProvider::boot() — the flat alias form is mandatory.
use Livewire\Livewire;
Livewire::component(
'my-app-landing-register',
\Modules\MyApp\Livewire\Landing\RegisterWidget::class,
);
<?php
namespace Modules\MyApp\Livewire\Landing;
use Livewire\Component;
class RegisterWidget extends Component
{
/** settings_schema values for the served locale, as the editor stored them. */
public array $settings = [];
public function render()
{
// Reads exclusively via App\Services\PartnerApi\* — the service
// checks the scope internally and refuses what is not in the context.
$grades = app(\App\Services\PartnerApi\Education\Grades\ListGrades::class)(
onlyActive: true,
);
return view('my-app::livewire.landing.register-widget', [
'grades' => $grades->data,
'title' => $this->settings['title'] ?? 'سجّل الآن',
]);
}
}
That is all for a static widget. An interactive one extends
PublicWidgetComponent instead of Component, lives under
app/LandingWidgets/, and writes through withPublicWrite() as in its
section above.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
| Widget never renders | Alias contains :: |
Flat form in livewire_component and in Livewire::component() alike |
| "Just doesn't work" during development | Silent failure + 30s failure cache | Check the okta-web logs; do not use page refreshes as a test |
| Works in preview, empty on the public site | A scope not marked public_surface or not declared in scopes |
The preview runs with a full admin context; test with the poor context before publishing |
| Buttons unresponsive on the site | A static widget with wire:* in it |
Interactivity requires interaction=interactive and its full contract |
| Every write refused with a barrier message | Missing @include(... form-guard) |
Once, inside the root element |
| 419 on the first click on a custom domain | SESSION_DOMAIN pinned to the platform domain |
A deployment precondition — see the SESSION_DOMAIN trap |
| A setting value shows as literal text | All settings values are escaped at render |
Intended — never HTML through settings |
Pre-publish checklist
-
integrationType=embeddedand thelanding_widgetsblock ≤ 10 widgets. - Every
keymatches^[a-z][a-z0-9_]{1,39}$, unique within the app — and its name is final. -
livewire_componentin the flat form (no::), registered withLivewire::component()inboot(). -
scopesis the minimum the render actually needs — not a copy of the installation's scope list. - No people/finance scope called from the public render path — it will never be marked
public_surface. -
settings_schema≤ 20 fields, types from the eight, and everysettingsread behind?? $default. - A static widget has no
wire:*in its blade. - Interactive: extends
PublicWidgetComponent, underapp/LandingWidgets/, and every write insidewithPublicWrite(). - Interactive:
@include('landing-builder::blocks.partner-widget.partials.form-guard')once inside the root element. - All
public_writesare markedpublic_writablein the catalog andinteraction=interactive. - Interactive tested on a custom tenant domain if your tenants use them.
- The render tested with the poor context (no user,
scopesonly), in both page locales, and with an empty data state. -
preview.thumbnail_urlis a working https URL, and the card reviewed in the editor.
External apps
When you create an External app you provide:
webhook_url: HTTPS URL that receives tenant events.webhook_events: subscription list.redirect_urls: OAuth callback URLs.
When a tenant installs your app, an installation token unique
to that (tenant, app) pair is issued. Use this token on every API
call. The token is long-lived until rotated or revoked. On rotation
you receive a webhook partner.installation.token_rotated; the old
token remains valid for 15 minutes for graceful deploy.
Notification apps
A notification app registers as a pluggable channel that the rest of
the platform consumes through one unified interface. You build the
provider once; the platform calls it with send(recipient, message)
regardless of the underlying medium (WhatsApp, SMS, Push, Slack, ...).
Manifest block
{
"integrationType": "notification",
"notification": {
"channels": ["whatsapp", "sms"],
"delivery": "api",
"api": {
"send_endpoint": "https://your-app.example/notifications/send",
"auth": "hmac"
},
"embedded": {
"provider_class": "Modules\\AcmeSms\\Providers\\AcmeSmsProvider"
},
"capabilities": {
"supports_templates": true,
"supports_media": false,
"supports_bulk": true,
"max_bulk_recipients": 1000
},
"settings_ui": {
"has_settings_page": true,
"livewire_component": "partner-apps.acme-sms.settings"
}
}
}
Validation rules enforced by the platform:
notification.channelsis a non-empty array of known values:whatsapp,sms,push,slack,email,telegram,voice.notification.deliverymust beapi,embedded, orhybrid.- If
deliveryisapiorhybrid⇒notification.api.send_endpointis required and must be HTTPS. - If
deliveryisembeddedorhybrid⇒notification.embedded.provider_classis required (must be a valid fully-qualified PHP class name likeModules\Foo\Providers\Bar). - A
notificationblock is rejected unlessintegrationTypeis set tonotification.
Scopes (granted automatically)
Picking integrationType=notification auto-attaches:
notifications.providers.send(required)notifications.logs.read(optional)
Path 1: delivery=api
The platform sends:
POST https://your-app.example/notifications/send
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Delivery-Id: <uuid>
{
"channel": "whatsapp",
"recipient_identifier": "+966500000000",
"message": {
"body": "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.deliverymust beapi,embeddedorhybrid.delivery=api/hybrid⇒payment.api.charge_endpointmust be HTTPS.delivery=embedded/hybrid⇒payment.embedded.provider_classmust be a valid FQCN.- A
paymentblock is rejected unlessintegrationType=payment.
Custom payment methods
Beyond the nine standard methods, a payment provider may declare custom
methods via a custom_methods block alongside payment_methods:
"payment": {
"payment_methods": ["card", "mada", "my_regional_wallet"],
"custom_methods": [
{ "key": "my_regional_wallet", "label": "محفظتي الإقليمية", "label_en": "My Regional Wallet", "kind": "wallet" }
],
"delivery": "api"
}
Rules:
key: matches^[a-z][a-z0-9_]{1,31}$, must NOT equal any standard value, and is unique within the list.label(Arabic) andlabel_en(English): required, 2–60 chars each.kind: required, one ofcard|wallet|bnpl|transfer|cash|other.- Every
custom_methodskey MUST appear inpayment_methods(and vice versa: any non-standardpayment_methodsentry must have a matchingcustom_methodsentry). The create wizard appends custom keys intopayment_methodsautomatically — you never tick them by hand. custom_methodsis omitted from the manifest entirely when empty.
Consumer apps see custom methods (with their labels) via
GET /api/apps/payments/methods exactly like the standard ones, and pass
the key when creating a charge.
Scopes (granted automatically)
Picking integrationType=payment auto-attaches:
payments.charges.update(required — lets you update charge status)payments.charges.read(optional — read charge status)
Path 1: delivery=api
okta-web sends your endpoint a signed charge request:
POST https://your-gateway.example/okta/charge
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Charge-Id: <uuid>
X-Okta-Idempotency-Key: <key>
{
"method": "tabby",
"amount": 150.00,
"currency": "SAR",
"description": "Annual subscription",
"customer": { "name": "Ahmed", "phone": "+966500000000", "email": "a@example.com" },
"metadata": {},
"charge_ref": "chg_01H..."
}
- Signature:
X-Okta-Signature=hmac_sha256("<timestamp>.<body>", signingSecret)— same scheme as notification. - No automatic retry from okta-web; idempotency is handled via the
X-Okta-Idempotency-Keyheader so a replayed request never double-charges.
Expected response (2xx):
{
"status": "pending",
"provider_ref": "tabby_pay_123",
"redirect_url": "https://checkout.tabby.ai/..."
}
statusis one ofpending|paid|failed.redirect_urlis optional — for flows that redirect the customer.
Status updates flow back later via
POST /api/apps/payments/charges/{ref}/status using the provider's own
installation token (scope payments.charges.update).
Path 2: delivery=embedded
You ship a class inside okta-web implementing
App\Contracts\PartnerPaymentProvider (charge / refund / supports /
isConfigured). Never throw from charge() — wrap in try/catch and return
a failure result.
Path 3: delivery=hybrid
The platform tries the embedded provider first and falls back to the api
path on isConfigured()=false or a non-permanent failure.
Consuming payments from your app (Embedded only)
Strict rule: payment consumption (create / read / list / refund a charge) is available exclusively to Embedded apps. Any attempt from an External app is rejected with
payment_consumption_embedded_only. Reason: reacting to a charge outcome happens via an in-process event, not a webhook — an externally-hosted app has no way to listen for an internal Laravel event, so it would stay blind to the final outcome if allowed to charge. Payment providers themselves (integrationType=payment) don't consume either — their role is only to receive the charge call and push status updates (payments.charges.update), never to initiate charges for other apps.
How it works, end to end
- The tenant installs one or more payment providers
(
integrationType=payment) from/partner-apps/payment/providers— an administrative step the tenant takes; your app has no part in it. - Your embedded app requests a charge through the uniform
CreateChargecontract without knowing which gateway the tenant actually installed. - The platform (
ResolveTenantPaymentProvider) resolves the tenant's active provider that supports the requested method and executes the charge through it (the same api/embedded/hybrid paths described above from the provider's side). - Your app follows up on the result in two complementary ways:
- Immediately, from the response itself
(
status: paid|pending|failed). - Later, via the
ChargeUpdatedevent the platform fires on every status transition — needed forpendingcharges that later becomepaidthrough BNPL gateways once the customer completes checkout.
- Immediately, from the response itself
(
Required scopes
Pick these from the scope picker when building your app (payments
resource):
| Scope | Grants |
|---|---|
payments.methods.read |
Read the tenant's available payment methods |
payments.charges.create |
Create a charge — requires idempotency_key |
payments.charges.read |
Read one charge or list all of your own charges |
payments.refunds.create |
Refund a charge you previously created |
payments.charges.updateis not in the consumer picker — it is exclusive to payment providers themselves (auto-granted only forintegrationType=payment, to update the status of the charges they execute). Calling it from a consumer context is rejected withpayment_status_update_provider_only.
Services (in-process — the primary path)
Same pattern as the rest of App\Services\PartnerApi\* — no HTTP, no
exceptions for normal control flow, scanner-clean. Every service returns
an array (no Eloquent, no schema leaking):
| Service | Signature | HTTP equivalent | Scope |
|---|---|---|---|
ListAvailableMethods |
__invoke(): array |
GET /api/apps/payments/methods |
payments.methods.read |
CreateCharge |
__invoke(array $input): array |
POST /api/apps/payments/charges (+ Idempotency-Key) |
payments.charges.create |
GetCharge |
__invoke(string $chargeRef): array |
GET /api/apps/payments/charges/{ref} |
payments.charges.read |
ListCharges |
__invoke(array $filters = []): array |
GET /api/apps/payments/charges |
payments.charges.read |
RefundCharge |
__invoke(string $chargeRef, array $input): array |
POST /api/apps/payments/charges/{ref}/refund |
payments.refunds.create |
ListCharges returns only the charges your own app created
(filtered by your installation id) — supported filters: status,
method, from, to, plus page/per_page (up to 100), newest first.
Example 1 — selling a feature/service from your app
// Modules/SchoolPortal/app/Services/Reports/SellPremiumReport.php
namespace Modules\SchoolPortal\Services\Reports;
use App\Services\PartnerApi\Payments\CreateCharge;
final class SellPremiumReport
{
public function __construct(
private readonly CreateCharge $createCharge,
) {}
/**
* @return array<string, mixed>
*/
public function __invoke(string $orderId, float $amount): array
{
return ($this->createCharge)([
'method' => 'tabby',
'amount' => $amount,
'currency' => 'SAR',
'description' => 'Premium analytics report',
'metadata' => [
'feature' => 'premium_reports',
'order_id' => $orderId,
],
'idempotency_key' => "premium-report-{$orderId}",
]);
}
}
From the calling Livewire component:
$result = app(SellPremiumReport::class)($orderId, 49.00);
if ($result['status'] === 'paid') {
// Unlock the feature immediately — the response came back paid
// synchronously (card/mada).
$this->dispatch('feature-unlocked');
return;
}
if ($result['status'] === 'pending' && $result['redirect_url']) {
// BNPL gateways (Tabby/Tamara) — send the customer to finish there.
$this->redirect($result['redirect_url']);
return;
}
if ($result['status'] === 'failed') {
$this->addError('payment', __('Payment failed. Try another method.'));
return;
}
// pending with no redirect_url — wait for the ChargeUpdated event (example 2).
Never re-call CreateCharge on a page reload or a network failure — always
pass the same idempotency_key; the platform returns the same stored
charge instead of charging twice (no automatic retry on charging).
Example 2 — unlocking a feature when the payment completes (ChargeUpdated)
A charge that started pending (BNPL gateway) later transitions to
paid/failed outside the original CreateCharge request — no webhook
reaches you for this (consumption is in-process only); listen for the
event inside your own app's ServiceProvider boot():
// Modules/SchoolPortal/app/Providers/SchoolPortalServiceProvider.php
namespace Modules\SchoolPortal\Providers;
use App\Events\PartnerPayments\ChargeUpdated;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\SchoolPortal\Services\Reports\GrantOrRevokePremiumReport;
class SchoolPortalServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(function (ChargeUpdated $event): void {
// Mandatory filter — the platform broadcasts the same event
// to every listening app, not just yours.
if ($event->consumerModuleSlug !== 'school-portal') {
return;
}
if (($event->metadata['feature'] ?? null) !== 'premium_reports') {
return;
}
$orderId = $event->metadata['order_id'] ?? null;
match ($event->status) {
'paid' => app(GrantOrRevokePremiumReport::class)->grant($orderId),
'refunded', 'partially_refunded' => app(GrantOrRevokePremiumReport::class)->revoke($orderId),
default => null, // failed — nothing further; pending never reaches here
};
});
}
}
Important notes about the event:
- It only fires on an actual status change — you will not receive it
for the initial
pendingcreation (that is not a "transition"), only forpending→paid|failedor a laterpaid→refunded|partially_refunded. A failed refund does not fire it. - Make your handler idempotent — check your own current state before granting/revoking, in case of a listener restart or rare duplicate.
- Always handle
refunded/partially_refunded— the event may reach you later for a charge that previously unlocked a feature; turn it off. - Available fields:
chargeRef,tenantId,consumerModuleSlug,previousStatus,status,method,amount,currency,refundedAmount,metadata,occurredAt.
Example 3 — reconciliation
use App\Services\PartnerApi\Payments\ListCharges;
$page = app(ListCharges::class)([
'status' => 'paid',
'from' => '2026-07-01',
'to' => '2026-07-31',
'per_page' => 100,
]);
foreach ($page['data'] as $charge) {
// $charge['charge_ref'] is the only public identifier — never a numeric id
// Match $charge['metadata']['order_id'] against your own records
}
$page['pagination']; // total / per_page / current_page / last_page
Refunds
use App\Services\PartnerApi\Payments\RefundCharge;
$result = app(RefundCharge::class)($chargeRef, ['amount' => null]); // null = full refund
RefundCharge guards:
- The charge must be owned by your app (created by it — otherwise it is treated as not found).
- Its current status must be
paid(no refund onpending/failed). amount≤ the remaining refundable balance (amount − refunded_amount) — previous partial refunds are respected automatically.- No automatic retry here either; a failed refund does not fire
ChargeUpdated.
Per-version overrides
Every payment field above is overridable per version from the Integration tab. Blank fields inherit module-level values at manifest-build time.
App Control Panel
The developer builds their app's control panel inside the app code; the platform owns the design + access controls and opens it from the partner portal via a signed link. Each app has two panels — one per environment (Production and Sandbox) — based on the version published/installed in each. On top of what you build yourself, the platform automatically adds an "App Data" section to every panel's chrome — an isolated key/value store for your app that you manage with zero code (see App Data below).
How it works
- Declare the panel in the manifest via a
developer_uiblock. - Two cards appear on the portal's Integration tab: Production and Sandbox. Each is enabled only when a version is live in that environment and the panel is declared in the manifest.
- The "Open panel" button hits a lightweight okta-partners endpoint
(
GET /partner/modules/{slug}/dev-panel/{env}) that mints a short-lived JWT (5 min) and redirects you to the panel page on the target okta-web environment:<web base>/partner-dev/{slug}/panel?token=<jwt>. - The JWT is HS256-signed with the same shared bridge secret each
environment's outbound bridge calls already use (production ⇒
outboundToken, sandbox ⇒sandboxOutboundToken) — no new secret. Claims:{ iss:"okta-partners", aud:"okta-web-devpanel", sub:<partner user id>, module:<slug>, env:"production"|"sandbox", iat, exp:iat+300, jti }.
Manifest block
For any integration type (embedded / payment / notification / external):
"developer_ui": {
"has_developer_page": true,
"livewire_component": "my-app-developer-panel"
}
- embedded / payment / notification ⇒ set
livewire_component(a Livewire component alias rendered inside okta-web; pattern^[a-z0-9][a-z0-9-]*(::|-)[a-z0-9-.]+$— use the hyphen form, e.g.my-app-developer-panel; a::alias is not resolvable by Livewire 4). - external ⇒ set
urlinstead (an HTTPS URL you host on your own environment — your environments are your own, so you get one card that opens the URL directly in a new tab):
"developer_ui": {
"has_developer_page": true,
"url": "https://partner.example/console"
}
The two are mutually exclusive (XOR): the manifest emits only the key matching the integration type. The block is overridable per version from the Integration tab; a blank value inherits the module-level value.
Panel sections (sections)
Who owns what — everything below follows from this split:
| The platform owns | You own |
|---|---|
| The panel shell (chrome) and outer frame | The content of your own sections |
| The header, app name and app icon | One Livewire component per section |
| The section navigation and its order | Each section's label and icon (from the platform's list) |
| The built-in sections (App Data, health, ...) | — |
The platform fixes the built-in sections in every panel; sections is
your half: app-specific sections hung off the platform's navigation, each
rendering one of your Livewire components inside the shell. The key is
optional — omitting it gives you a single-section panel, exactly the
behaviour that existed before the key did.
"developer_ui": {
"has_developer_page": true,
"livewire_component": "my-app-developer-panel",
"sections": [
{ "key": "trips", "label": "الرحلات", "label_en": "Trips",
"icon": "map", "livewire_component": "my-app-trips" },
{ "key": "drivers", "label": "السائقون", "label_en": "Drivers",
"icon": "users", "livewire_component": "my-app-drivers" }
]
}
| Field | Required | Rule |
|---|---|---|
key |
^[a-z][a-z0-9-]*$ — must start with a lowercase letter (never a digit), then lowercase letters/digits/hyphens, no underscore, max 32 chars, and unique within the array (the panel navigation is keyed by it). |
|
label |
Max 60 chars (the Arabic/default-locale label in the navigation). | |
label_en |
Max 60 chars. | |
livewire_component |
The same alias rule as the panel's own component — and :: is flattened the same way, because Livewire 4 cannot resolve a name containing ::. |
|
icon |
One of: home, chart, list, map, settings, bell, users, box, calendar, file. |
- Maximum 10 sections; array order is navigation order.
- The
keyrule here is deliberately narrower than what okta-web accepts (^[a-z][a-z0-9_-]{0,39}$): okta-partners produces the manifest and okta-web consumes it, so the producer is the stricter side. A key we let through but okta-web rejects would be a publish failure caused by data we waved through. - External apps have no
sections: they host the whole panel themselves, so there is no platform shell to hang sections off — the key is dropped exactly aslivewire_componentis. - A half-valid block never ships: a section missing or malforming
key,labelorlivewire_componentis dropped whole — a nav entry that routes nowhere is worse than a missing one. A duplicatekeykeeps only its first occurrence, an unrecognised icon is dropped silently (the section stays, iconless — decoration never fails a declaration), and the list is capped at 10.
Panel design policy
The panel lives inside a shell the platform owns, and every rule below follows from that. All of them are literal:
- Do not build a header or a nav bar inside your section. The shell already provides both; anything you add renders duplicated above the platform's own header.
<x-…>components only. No raw<button>/<input>/<select>/<textarea>/<table>— the visual identity lives inside the components.- No fixed colours such as
gray/zinc/slate/indigo; use the platform tokens (neutral,primary,success,warning,danger). - No
max-w-*. The shell enforces the width; adding it breaks the grid and clips long content. - The section's name comes from
label/label_en, not from the blade — the platform draws the title in the navigation. - Icons come from the allowed list only; never ship your own SVG into the navigation.
Rules 2–4 are enforced by the UiScanner design scanner and the CI gate over
every *.blade.php in your repo, so a violation never reaches production.
The controls
- Design: the panel blade goes through the same UiScanner / CI gate as the
rest of your pages —
<x-…>components + platform tokens only, no raw HTML, nogray/zinc/slate/indigo, nomax-w-*. - Access: platform-issued signed link only. The developer context has no
tenant — you see your app's aggregate health across all installs, never a
single tenant's data. Your panel component can only reach one namespace:
App\Services\PartnerApi\DeveloperPanel\*— the read-only aggregates (GetInstallsCount, ...) plus App Data CRUD underDeveloperPanel\Settings\*(see the next section).
Full example
Livewire component (shipped in the boilerplate under
app/DeveloperPanel/Panel.php.example):
namespace Modules\MyApp\app\DeveloperPanel;
use App\Services\PartnerApi\DeveloperPanel\GetInstallsCount;
use Livewire\Component;
class Panel extends Component
{
// The only two inputs the platform passes — never a tenant id.
public string $moduleSlug = '';
public string $environment = 'production';
public function render(): \Illuminate\View\View
{
$installs = app(GetInstallsCount::class)($this->moduleSlug);
return view('my-app::developer-panel.panel', [
'installs' => $installs,
]);
}
}
The blade uses platform components only:
<div class="space-y-6">
<x-badge :color="$environment === 'production' ? 'success' : 'warning'" variant="soft">
{{ $environment }}
</x-badge>
<x-card>
<div class="p-4">
<p class="text-3xl font-bold text-[var(--color-neutral-900)]">{{ $installs }}</p>
</div>
</x-card>
</div>
Available stats
The App\Services\PartnerApi\DeveloperPanel\* read surface on okta-web exposes
aggregates across all of your app's installs (never a single tenant's rows) —
e.g. installs count (GetInstallsCount), active installs, and error counts.
Any payment charge surfaced in these aggregates is called a "دفعة".
App Data
A key/value (JSON) table scoped strictly to your app — a low-friction way to store operational settings/flags you want to tune between releases without a new publish.
- Zero-code manager UI: an "App Data" section appears automatically inside the panel chrome — every app gets full CRUD (add/edit/delete a key) with no code of your own.
- Two service surfaces:
- From your own panel component (namespace allowed only in the panel
context):
App\Services\PartnerApi\DeveloperPanel\Settings\{ListSettings, PutSetting, DeleteSetting}— each takes your app'smoduleSlug. - From your app's embedded code at runtime, in front of tenants
(read-only, no writes):
App\Services\PartnerApi\AppSettings\ {GetAppSetting, GetAppSettings}— cached for ~60 seconds.
- From your own panel component (namespace allowed only in the panel
context):
- Limits: 100 keys max per app, each value ≤ 64KB, key format
^[a-z][a-z0-9_.-]{0,119}$. - Isolation: fully server-enforced — the panel session context is derived
only from the verified JWT (
iss=okta-partners,aud=okta-web-devpanel); your app's session can never touch another app's data. - The pattern: tune your app's behavior (feature flags, limits, provider parameters, display texts) from the panel between releases — no republish needed.
Runtime read from your app's code (e.g. inside a service under
App\Services\PartnerApi\*):
use App\Services\PartnerApi\AppSettings\GetAppSetting;
$maxItems = app(GetAppSetting::class)('max_items_per_page', 20);
Write from an action inside your panel component:
use App\Services\PartnerApi\DeveloperPanel\Settings\PutSetting;
public function saveMaxItems(): void
{
app(PutSetting::class)($this->moduleSlug, 'max_items_per_page', (string) $this->maxItems);
$this->dispatch('saved');
}
Browser tools
A browser tool is code that runs inside a system the school already uses —
Noor, typically — not inside Okta. You write it, it ships inside your app's
version, and Okta serves it to the Okta Tools extension in the user's
browser. Valid for any integration type: a tool is a capability your app
ships, not a kind of app, so nothing gates it — not integrationType, not
another switch.
The governing rule: the extension runs it, Okta serves it
This rule changed. Partners used to host a .user.js file and Okta listed
the link for the user to install into Tampermonkey or similar. That is no longer
a supported path:
- The user downloads nothing and needs no userscript manager.
- The Okta Tools extension (Chrome Web Store) is the only thing that runs a tool.
- You write the body into the
scriptfield on the version, and Okta serves it from its own origin, to the extension alone.
Three things follow that were not possible before:
- What a school runs is what was published and reviewed. It cannot change under them between reviews.
- A code update reaches everyone with no reinstall of anything. Publish a version; the extension picks it up.
- No auth wall silently kills auto-update, and no partner URL rots a year later leaving a dead tool nobody notices. Those were the three most common failures of the old path, and hosting removed all of them.
The old shape is still accepted: a published version carrying a
urlending in.user.jswith noscriptis neither rejected nor hidden — dropping it would have hidden a tool that works today for tenants who installed it. But do not write new tools that way; none of the three benefits above apply to it.
delivery: webstore is unchanged: an extension you distribute through the
Chrome Web Store, where the URL is the whole product.
Declaring one
In the portal, on your app's page: Integration tab → Browser tools → "Add a tool". The fields:
| Field | Required | Rule |
|---|---|---|
key |
^[a-z][a-z0-9-]*$, ≤ 32 chars, unique within your app. Okta addresses the tool as <module>:<key> |
|
name |
≤ 80 chars. name_en optional, same limit |
|
description |
— | ≤ 300 chars; description_en likewise |
delivery |
userscript or webstore |
|
script |
for userscript |
the tool body, ≤ 128 KB |
world |
— | isolated (default) or main. Injected tools only — read the next section before choosing |
url |
for webstore |
absolute https to the Web Store listing, ≤ 500 chars |
icon |
— | absolute https. A broken icon is stripped and the row survives |
version |
— | ≤ 20 chars, defaults to 1.0 |
match |
for userscript |
one pattern per line, ≤ 20 patterns, ≤ 200 chars each |
At most 10 tools per app. List order is display order for the tenant — your only control over which one they see first.
world: the most dangerous field here
The execution world is a functional choice, not a preference, and its effect only shows at runtime, in the user's browser:
| Value | What the tool sees |
|---|---|
isolated (default) |
the DOM tree only. Not the page's JavaScript globals or functions |
main |
the page's own world: its globals, its functions, its frameworks |
The practical rule: if your tool calls anything the page defines — $find
in ASP.NET apps, jQuery, an app's own client object — it needs main. Under
isolated those are simply undefined, and it fails with is not a function
while everything else looks fine: the tool is enabled, the buttons are drawn,
the page works. That is exactly what happened to the Noor export tool, and it is
the class of failure users never report, because they don't know what was
supposed to happen.
Start at isolated — if your tool only reads and writes the DOM that is enough,
and it is the narrower privilege. Raise it to main for this reason and no
other.
An unknown value (page, say) drops the whole tool rather than downgrading
it to isolated: a silent downgrade ships the exact failure described above.
The cross-checks that drop a row
The partner portal checks these and blocks the save, but know them — they are the most common mistakes:
delivery: userscriptwith noscript. The body travels inside the version, so a tool without one is not a tool. (Sole exception: the legacy shape with a.user.jsurl.)scriptandurlon the same row. One source of truth per tool — otherwise there is no telling which body a tenant is running.delivery: webstorewith ascriptbody. Nothing runs it: the Web Store extension is the artifact, so the body here is dead code that reads as live.delivery: userscriptwith nomatchpattern. It is the tenant's only disclosure of which sites your tool touches, and what the extension requests permission for.delivery: webstorewith a url ending in.user.js. A mistyped delivery.
Why we block you instead of accepting and fixing:
BuildBrowserToolsBlockdrops any row it cannot ship whole. Had we let it through, the form would save successfully, the manifest would come out one tool short, and you would find out from a tenant reporting a tool that never appeared. Failing at the form is the difference between "the portal told me my tool was incomplete" and "my tool vanished silently".
match patterns
Chrome match-pattern syntax: <scheme>://<host><path>.
https://noor.moe.gov.sa/*
https://*.moe.gov.sa/reports/*
Three things people get wrong:
*in the scheme meanshttp|httpsonly — not "any scheme".*.example.comincludesexample.comitself, not only its subdomains.- The path is mandatory:
https://noor.moe.gov.sawith no/is invalid.
Keep them as narrow as they can be. A broad pattern means a wider permission prompt for the user, and one more reason for them to refuse to run your tool.
The manifest shape
{
"browser_tools": [
{
"key": "noor-helper",
"name": "مساعد نور",
"name_en": "Noor Helper",
"description": "Speeds up grade entry in Noor.",
"delivery": "userscript",
"script": "(function () { 'use strict'; /* ... */ })();",
"world": "main",
"icon": "https://partner.example/tools/icon.png",
"version": "1.2",
"match": ["https://noor.moe.gov.sa/*"]
}
]
}
A ==UserScript== header is not required — the extension does not read it; the
declared match and world are what govern injection. Write one only as
documentation.
Version-level override
The list is owned at the app level and every version inherits it. A version may carry its own list, from the version editor ("Browser tools" card → "Customise the list for this version").
Know what that means before you do it:
- An own list beats the app list when the manifest is built. Any tool you add later at the app level will not reach that version.
- An empty own list is not inheritance, it is an explicit declaration that this version ships no tools at all. Use it to withdraw a tool in one version and keep it in another.
- Going back: "Return to the app list" on the same card clears the override and restores dynamic inheritance.
And since the body is now part of the version, an override also freezes the tool's code on that version.
Updating a published tool
Edit script, raise version, publish. The extension compares versions and
re-fetches the body when they differ, and skips the fetch entirely when nothing
changed. Nobody is asked to reinstall the extension or the tool.
Freezing the number freezes the tool on its old body for everyone who enabled it, with no visible symptom to anyone — so raise it on every edit, however small.
What a tool cannot do
A tool never reaches Okta, in either world: it carries no installation
token, reads none of your scopes, and cannot call /api/apps/* as your app.
world: main widens what it sees of the target page, not what it holds of
Okta. If you need data from Okta, that is your app's job, not its tool's.
Manifest
Every app emits a manifest.json describing its capabilities:
{
"moduleId": "warehouse",
"displayName": "Warehouse Manager",
"version": "1.0.0",
"category": "logistics",
"integrationType": "embedded",
"description": "...",
"scopes": [
{ "key": "education.students.read", "required": true, "reason": "to list student rosters" },
{ "key": "education.students.write", "required": false, "reason": "to record results" }
]
}
External apps add external block; notification apps add notification
block; payment apps add payment block (see above). Embedded apps that
extend the student profile add a studentProfile block (see Extending the
student profile).
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:
- NotificationScanner in CI: scans the codebase for any
DispatchNotification('<key>', ...)call and fails the PR when<key>isn't inmanifest.json["notifications"]. - Runtime guard on okta-web: 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=criticalsparingly — it breaks quiet-hours. - Keep
variables_schemasmall and meaningful. - Use
is_active=falseinstead of delete on a published version.
Common errors
| Error | Fix |
|---|---|
notification-key-not-declared |
Declare on the tab, pull manifest. |
notification-key-unused (warning) |
Remove key or wire the dispatch. |
cannot_edit_published |
Create new version, catalog auto-cloned. |
delete_blocked_by_installs |
Use is_active=false instead. |
key_prefix validation error |
Type only the suffix after <slug>.. |
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.access—readorwrite. Not two rungs of a ladder: offeringreaddoes not offerwrite, and the reverse is equally untrue. Declare two rows if you mean both.labelanddescription— written for the school, not for a programmer. This exact text is what appears on the store's consent screen. "Daily attendance records" can be read and decided on;att_rec_v2cannot.reader— the field that makes the offer yield anything. Everything else is consent paperwork establishing that a link may exist; this class is what the platform actually runs to produce the rows. Without it a school can grant the row and it returns nothing. It must live underModules\in your own code and implement the interface — see below.
Write the reader
namespace Modules\Attendance\Services\PartnerApi;
use App\Services\PartnerApi\Contracts\CrossAppReader;
use Modules\Attendance\Models\AttendanceRecord;
final class RecordsReader implements CrossAppReader
{
public function read(string $resource, array $filters, string $consumerSlug): array
{
return AttendanceRecord::query()
->when($filters['from'] ?? null, fn ($q, $d) => $q->whereDate('date', '>=', $d))
->limit(200)
->get()
->map(fn ($r) => [
'student_ulid' => $r->student->ulid,
'date' => $r->date->toDateString(),
'status' => $r->status,
])
->all();
}
}
Three rules that are not negotiable:
- Do not re-check permission here. The platform calls your class only after
establishing all three: that the consuming developer was granted this pair,
that this particular school consented to it, and that the caller is
$consumerSlug— taken from the active context, never from anything the caller sent. A second check adds complexity, not safety. $filtersis untrusted input. It comes from another app. Validate it as you would a$request, and cap the row count.- Return plain values, and ULIDs rather than numeric ids. The caller is a different codebase possibly deployed at a different version; handing back an Eloquent model couples the two products' schemas, and any later column rename becomes a cross-partner breaking change.
The class runs inside your context: your schema, your approved scopes, the current tenant. Scope your query to the current tenant exactly as you do on your own pages.
Embedded only. The reader is a PHP class living in code you ship inside okta-web. External apps can declare
providesand appear on the consent screen, but have no read channel yet.
Only what is published is visible. Other apps see
providesfrom your latest published version only. While it sits in a draft it does not exist for them — deliberately: a link is a commitment between two shipping products, and building one against a draft means what you depend on can change or vanish before anyone outside your team ever saw it.
2. If you need the data: send the request
/dashboard/app-links → "New access request". Pick your app, then the app that
owns the data, then — from its published list — what you need, then write
your reason.
The reason is not a formality: it is everything the other developer reads before deciding. Say exactly what you will show the user ("we show absence periods inside the pupil's schedule, nothing stored") rather than something generic ("for integration").
3. The reply: all, part, or refusal
The owning developer sees it under "Incoming" and can:
- Approve everything — the common case, one click.
- Approve part of it — "read the monthly summary, not the detailed register". That is the normal answer to a real request, not an edge case.
- Refuse — with a reason, required. A bare "no" leaves the other developer with nothing to change and no way to know whether asking again could ever work.
Approval needs no explanation; refusal does. After approving, a developer may still revoke (also with a reason) — after which your app can no longer declare that access in future versions.
4. Declare the dependency in your version
Once approved, the row appears in your app editor under "Data this app needs". Do not hand-write it — the list is built from live grants, so anything not in it cannot be declared at all. For each row you choose two things:
"cross_module_access": [
{
"module": "attendance",
"resource": "records",
"access": "read",
"required": true,
"reason": "To show absence periods inside the pupil's schedule."
}
]
-
required— the field the school actually feels:true→ the store asks it to install both apps together, and your app does not work without it.false→ the rest of the app works and only this feature degrades.
Get it backwards and a school is left unable to use what it paid for. Use
trueonly when the app is meaningless without the data. -
reason— appears verbatim on the store page next to the consent control. Write it for the school.
What happens at install
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
- The caller is the active module context, not something you sent.
- The school consented to this exact row —
(requester, provider, resource)— the card it saw in the store. The developer's approval alone is not enough. - The provider still publishes the resource and named a reader for it, in the manifest of the installed version — not in the row you were granted on approval day. Withdraw a resource and you stop answering for it.
The platform then enters the provider's context and calls its reader there, so it reads its own data with its own credentials, and your context is restored on the way back. You never touch its schema and learn nothing about it — by design: every install owns its own Postgres schema behind a restricted role, so reading directly is not merely forbidden, it is impossible.
A refusal is an exception, not an empty array
A row the school did not grant throws a RuntimeException whose message names
which gate closed; it does not return []. "There is nothing to read" and "you
are not allowed" are entirely different states, and conflating them makes you
ship a blank panel instead of a fixable error.
Which means you expect the exception and treat it as ordinary on a
required=false row:
try {
$records = app(ReadFromApp::class)('attendance', 'records');
} catch (\RuntimeException) {
$records = null; // render the schedule without the absence column.
}
On a required=true row it is only what happens between install and consent,
and does not recur afterwards.
Why not
CrossModuleAccessService::hasAccess()? This guide used to point you at it, and it was wrong twice over: the policy scanner rejects the import (internal-service-import—App\Services\PartnerApi\*is the whole contract), and it returns a boolean anyway, telling you that you were allowed to read while giving you no way to read.ReadFromAppchecks the same thing and then returns the data.
Limits worth knowing
writeis not enforced yet. okta-web currently matches on (tenant, requester, provider, resource) and does not compare theaccesscolumn, so a grant issued asreadpasses 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:
- It arrives as a draft and does not print. A template you just authored
is absent from
GET /reports/templatesandPOST .../renderanswers422for it. Not an error to work around: everything printed on a letterhead goes out in the school's name, and an app that could author and print in one step could issue documents nobody at the school ever saw. - Only what you authored. The school's own letterhead, and one authored
by another app on the same school, both answer
422on update or delete. (Tenant isolation says nothing here — both apps are legitimately installed.) - An edit revokes approval. Editing a template the school approved sends it back to draft and it stops printing until approved again. That is the cost and it is the point: an approval that survived an edit would let an app get one innocuous design signed off and then write anything into it.
- An approved one is no longer yours to delete. Approval is the school
taking the design as its own — it may be on a term's paperwork. Deletion
answers
422; the school removes it from its own builder, where the consequence is visible to the person choosing it.
POST /api/apps/reports/templates → 201, the template as the listing describes it
PATCH /api/apps/reports/templates/{id}
DELETE /api/apps/reports/templates/{id}
{
"name_ar": "كشف درجات",
"name_en": "Marks sheet",
"header_center_content": "كشف درجات — الفصل {{term}}",
"signatures_enabled": true,
"signatures": [
{ "title_ar": "معلم المادة" },
{ "title_ar": "مدير المدرسة" }
],
"variables": [
{ "key": "term", "label_ar": "الفصل الدراسي", "required": true }
]
}
Writable fields are an allow-list: identity (name_ar required,
name_en, description, type), paper (orientation, paper_size),
header_enabled/footer_enabled and the header_*_content /
footer_*_content slots (right/center/left), watermark_text and
watermark_opacity, the four margin_*_mm, the signature block
(signatures_enabled, signatures_columns, signatures_title,
signatures[]), and variables[]. Anything absent is not writable — notably
header_logo and background_image (a school's mark on its own paper, and
the file would arrive from outside the school entirely) and the approval
state (writable, an app would approve its own draft and every guarantee above
would be ornamental).
The logo and entity name are inherited, copied from a letterhead the school already approved — a proposal that arrived blank-headed would read as a broken app rather than a draft awaiting their logo.
On PATCH every field is optional and omission means "leave it alone",
never "clear it", so an app that forgets a key does not blank a school's
footer. The exception is variables and signatures, where an explicit
empty array means "remove them all".
A custom variable key that shadows a platform one (tenant.name) is
dropped silently — the same rule the school's own editor goes through, so it
cannot drift between the two ways in.
25 templates per installation. An app proposes a handful of documents it
knows how to fill; a buggy loop proposes thousands, and the school finds out
as a builder page it can no longer read. Over the cap answers 422.
Uninstalling deletes nothing. Templates you proposed stay with the school — reviewed, approved, printed on; they are the school's documents now, whoever drafted them. They only lose their attribution. So do not rely on a reinstall restoring your authorship: a new installation owns nothing a previous one authored.
Propose once at first run, not on every screen open, or the school's board fills with identical drafts awaiting an approval that will not come. List first and propose only when nothing fits — a letterhead the school arranged by hand always wins. And do not poll for approval: nothing on this surface reports when it happens, the decision can take days, and listing at the moment the user needs a template shows only the approved ones anyway.
Embedded apps use CreateReportTemplate, UpdateReportTemplate and
DeleteReportTemplate in the same namespace.
Security
Embedded
- ✓ Policy scanner (regex + PHPStan AST) on every PR.
- ✓ Postgres role/schema isolated from the platform.
- ✓
BlocksPartnerDirectAccesstrait refuses access outsideApp\Services\PartnerApi\*. - ✓ Every scope checked at runtime via
AppPermissionGuard.
External
- ✓ Installation token encrypted at rest.
- ✓ Webhooks signed + timestamped + replay-protected.
- ✓ HTTPS-only
webhook_url. - ✓ Okta-side rate limit (60 req/min per installation).
Your responsibilities
- Never log the installation token.
- Store
webhook_secretin a secret manager. - Apply your own rate limits if forwarding tenant data.
- Don't store tenant data beyond what you need.
Local testing
Sandbox tenant
Every Embedded app gets a sandbox tenant with seeded data. Free, unlimited.
Webhook tunneling
For External apps use ngrok:
ngrok http 8000
Paste the URL into the sandbox app's webhook_url. Replay any past
delivery from "Webhook Deliveries → Replay".
Postman / Insomnia
Import https://partners.getokta.io/docs/openapi.json or the
Postman collection. Paste a
sandbox token into the {{token}} variable.
AI-assisted development (MCP)
The platform ships two MCP (Model Context Protocol) servers that connect your coding assistant (Claude Code, Cursor, or any MCP client) to the Okta standards and tooling — so your assistant follows the platform rules from the first line instead of discovering violations at PR review.
1) Local server — inside your repo (no account needed)
Every app repository scaffolded by the platform ships a ready local MCP
server in scripts/mcp/ with a root .mcp.json:
- Claude Code: auto-discovers it when you open the repo — zero setup.
- Cursor: add to
.cursor/mcp.json:
{ "mcpServers": { "okta-partner": { "command": "php", "args": ["scripts/mcp/server.php"] } } }
Requires only php on your PATH (no Composer, no network). Its tools wrap
the exact CI scanners (scan_code, validate_manifest,
lint_permission, component_catalog, feature_service,
search_standards, list_scopes) — whatever passes locally passes the
merge gate. See scripts/mcp/README.md in your repo.
2) Hosted server — from your account (consent, no tokens)
The hosted server adds live, identity-bound tools on top of the standards: your apps and versions, the live scope catalog, monitoring (app health and webhook deliveries), the app-preview simulator, and control / app-control-panel tools.
Connect (one-time):
# Claude Code
claude mcp add --transport http okta-partner https://partners.getokta.io/mcp
// Cursor — .cursor/mcp.json
{ "mcpServers": { "okta-partner": { "url": "https://partners.getokta.io/mcp" } } }
The first use opens your browser on the partner platform's consent screen: sign in with your normal account, review what the tool will be able to do, and approve. No token is ever copied or pasted — the grant is full OAuth (PKCE).
Write access: by default the link is read-only (your apps + standards + monitoring). To enable the control tools (submit for review, create a version, edit the changelog and release channel, set the account types, program the app control panel and webhook settings) tick "Allow write (app control)" on the consent screen. All writes go through the same guards as the portal UI (no bypassing publish state, no writing to a published version).
Key live tools:
| Tool | Purpose |
|---|---|
whoami / list_my_modules |
your identity and your tenant's apps |
get_module / list_versions |
app details + built manifest + versions |
module_health |
health report: status, versions, deployment checks, sandbox |
webhook_deliveries / delivery_stats / replay_delivery |
External delivery history, stats and replay |
preview_module / store_listing |
store-card & dashboard-placement simulation + preview page URL |
create_module |
create a new draft app — the first step, before create_version (write access required) |
submit_module / create_version / update_changelog / set_release_channel / sync_from_manifest |
lifecycle control (write access required) |
get/set_developer_ui / get/set_webhook_config |
program the app control panel & settings (write access required) |
set_landing_widgets |
declare your embedded app's landing-page widgets directly — or as a per-version override — without going through manifest.json. The list you pass replaces what is stored; [] clears the declaration (on a version that drops the override, restoring inheritance). The rules are okta-web's own publish rules (write access required) |
get/set_notification_config |
read and write the notification/payment provider block — at module level or as a per-version override (writing requires write access) |
list_notifications / create_notification / update_notification / delete_notification |
the catalogue of notifications a version dispatches (writing requires write access) |
discover_notifications / import_notifications |
scan the repo for undeclared notification keys and import them (importing requires write access) |
list_account_types / get_account_types / set_account_types |
the account-type catalog, what your versions declare, and setting it (write access required) |
list_shared_data |
what other apps share with other apps — the list you pick from before asking for access. Read from each app's latest published version, so anything still in a draft does not appear |
list_app_links / request_app_access / answer_access_request / revoke_app_access |
cross-app links: list them in both directions, request access, answer a request, and revoke a grant you gave (writes require write access) |
set_shared_data |
declare what your app shares with others, on an editable version — the first step, since nobody can request what you have not declared. Whole-list replace; [] clears it. Labels and descriptions are written for the school, not the developer (write access required) |
get_app_dependencies / set_app_dependencies |
what your app needs from other apps: what it declares today, what it COULD declare (granted to you and still published), and setting it on an editable version. Whole-list replace, and no entry without a live grant behind it (setting requires write access) |
get_org_profile / set_org_profile |
your organization and developer profile as the store shows it, plus your interface language. The account password is never readable or writable through MCP, and the logo is uploaded from the portal only (writing requires write access) |
optimize_screenshots |
re-encode a version's stored screenshots as WebP and scale down anything wider than needed. New uploads are already converted, so this is for an older library (write access required) |
Account types over MCP
The version editor's "Account types" tab is fully reachable from your assistant:
list_account_types— the live catalog (the same list the portal offers): each type has akey, atarget(role= a tenant role,portal= a cross-tenant portal) and Arabic/English labels. A key outside the list is accepted as a custom tenant role; the portals are a closed set (student|guardian).get_account_types— what your app actually declares. Account types are declared per version (there is no module-level value to inherit), so the report lists every version; pass{version}to narrow it.set_account_types— a whole-list replace on an editable version, exactly like the version editor's save. Read the current list first and resend it complete with your edit;account_types: []clears it.
Each entry: key (must equal the role name or the portal) + kind
(primary | dependent) + exactly one target: roles (one role) or
portal + at least one surface: web_route (a Laravel route name such as
school-app.admin — not a URL and not a /path) and/or mobile_entry (in
the shape the version's mobile mode requires). This single declaration is what
builds menu.audiences[] in okta-web and mobile.audiences[] in the Okta
app, and the tool refuses up front whatever the publish-time validator would
refuse.
Custom tenant roles. A role key outside the account-type catalog is
allowed — tenants define their own roles — but it is declared explicitly: the
audience it produces carries "custom": true in the manifest, and
set_account_types warns you when it stores one. Publishing an out-of-catalog
key without that marker is rejected, on purpose. The platform matches a role
key as plain text against the roles users actually hold, so an invented or
mistyped key matches nobody: the version publishes, the manifest looks
healthy, and every page of that audience is then refused for every user by the
deny-by-default audience guard. If you meant a known type, pick its exact key
(list_account_types); if you really do mean a tenant-defined role, make sure
the tenant has it under exactly that name. custom is never valid on a
portal audience.
Security note: the token is bound to you and your tenant only — it can never see or touch another tenant's apps, and webhook secrets are never readable through any tool. You can re-link at any time; write consent is your call on every link.
Design system & UI
Embedded apps render inside the tenant dashboard alongside okta-web's native screens, so visual identity must match. Hard rules:
- Use existing components:
<x-card>,<x-button>,<x-badge>,<x-input-field>,<x-textarea>,<x-alert>,<x-spinner>,<x-modal-card>. No custom components, no third-party UI libs. - Tailwind palette for neutrals and state colours. Your own brand colour has a supported home — see "Room to design" below; what is barred is a raw hex with no dark counterpart.
- RTL-first:
start/end,ms-/me-,ps-/pe-,text-start/text-end. Mirror arrows viartl:rotate-180. IDs/ URLs alwaysfont-mono+dir="ltr". - Card-based composition: each section in
<x-card>. Card paddingp-4mobile /p-5 md:p-6hero. - Single sticky save bar at the bottom for long forms.
- Explicit states: empty / loading / error are first-class.
- Modals via wire-elements/modal exclusively. Open with
wire:click="$dispatch('openModal', { component: '<slug>', arguments: {...} })". Close with$this->closeModal()or$dispatch('closeModal'). - Toasts via
$this->dispatch('toast', message: ...). - Animations come from the
motion-*layer, not your own keyframes —prefers-reduced-motionis handled there once (see "Room to design").
For the full component catalog with props/slots/examples and the
ready-made AI prompt that bakes in all the above rules, see commit
a164484 on prod or commit eb910cf on main. Both have the
expanded Design System section. The condensed version here is kept
for the prod doc budget — the AI prompt content is identical in
substance.
Room to design — what is actually yours
The rules above protect the foundation: the tokens, the dark mode, RTL, and the accessibility wiring on form controls. They do not mean every app has to look like the same app. This section is what is genuinely yours, and it exists because most of the reasons partners abandoned the design system were options that already existed, or should have.
Ask your assistant first: the
component_catalogMCP tool lists every component with its props and examples, the motion classes, your own brand colour, and which policy rules fail a build versus which are advice.
1. The card is flexible now
<x-card> had exactly one look, so anyone who needed another abandoned the
component and hand-rolled a div — losing the tokens and the dark mode to buy a
shape. Now the shape comes from the component:
<x-card tone="accent" padding="none" overflow="visible" elevation="lifted">
| Prop | Values | When |
|---|---|---|
tone |
raised (default) · sunken · plain · accent |
plain = grouping with no visible box; accent = your brand colour |
elevation |
flat · raised · lifted |
lifted rises on hover |
padding |
default · tight · none |
none for a table, a map, a media strip that must bleed to the card's edge |
radius |
default · lg · none |
|
divider |
true (default) · false |
The rule under the header |
overflow |
hidden (default) · visible |
For a menu that has to escape the card |
All optional, and every default reproduces the old look exactly — nothing that exists changes.
2. Your own colour
The only route to a brand colour was a literal, bg-[#0F766E]. A literal has
one value, so it is right in one theme and wrong in the other — and the
platform cannot help, because a hex inside a class string is opaque to it.
Declare it in the manifest with both values and it becomes something the platform knows:
"brand": {
"accent": { "light": "#0F766E", "dark": "#2DD4BF" },
"accent_contrast": { "light": "#FFFFFF", "dark": "#042F2E" },
"accent_soft": { "light": "#CCFBF1", "dark": "#134E4A" }
}
Then use it as a variable — it follows the theme the way platform tokens do:
<div class="bg-[var(--app-accent)] text-[var(--app-accent-contrast)]">…</div>
<x-card tone="accent">…</x-card>
- Hex only (3, 6 or 8 digits). The value is written into a
<style>tag, so anything else —rgb(...),var(...),color-mix(...)— is dropped rather than escaped. - Three roles, no more. A longer list is a second theme system, and a second theme system is how the platform's own stops meaning anything.
- Scoped to your pages via
[data-app-brand]— it never reaches the platform chrome or another app on the same screen. - Declared only
light? Dark inherits the same value, so nothing is half-painted. - A malformed role drops on its own and does not take the others with it.
3. Motion
A ready layer instead of writing @keyframes:
| Group | Classes |
|---|---|
| Entrances | motion-fade · motion-rise · motion-slide-in · motion-scale-in |
| Attention | motion-pop · motion-shake · motion-pulse |
| Interaction | motion-hover-lift · motion-press |
| Modifiers | motion-slow · motion-delay-1..3 |
<div class="motion-stagger">
@foreach ($rows as $row)
<x-card class="motion-rise">…</x-card>
@endforeach
</div>
motion-stagger on the container starts each child 40ms after the last — for
ten steps, then it stops adding delay, so the tail of a long table does not
arrive a second and a half late.
Why use these rather than your own keyframes: prefers-reduced-motion is
handled for all of them in one place. Someone who turned motion off in their
OS did not do it as a preference — for a person with a vestibular disorder,
motion they did not ask for is a symptom, not decoration. Every keyframe you
write yourself is one more place that has to remember them.
And retime freely: --motion-duration on any element retimes everything under
it, and 0ms stops it.
4. The gate has two channels, not one
- Fails the build — what breaks outside your design: raw
<input>/<select>/<textarea>, thegray|zinc|slate|indigoneutrals, and container-scalemax-w-*(3xland wider,full,screen-*). - Reported, does not fail — consistency you may overrule: a raw
<button>, and a hard-coded colour literal.
max-w-prose and max-w-[42ch] are not violations — that is typography,
not a container. And a raw <table> is no longer a violation at all:
<x-table> cannot express a timetable or a calendar, and a rule everyone
overrides teaches people to override rules rather than respect them.
5. Modals
Do not build one yourself with x-show/x-data and a hand-rolled
backdrop. The platform uses wire-elements/modal, and the stack is already
registered in the layout — do not register it again.
1. The component extends ModalComponent, not Component:
use LivewireUI\Modal\ModalComponent;
class WithdrawalRequestModal extends ModalComponent
{
public int $moduleId;
public static function modalMaxWidth(): string
{
return 'lg'; // sm | md | lg | xl | 2xl …
}
public function mount(int $moduleId): void
{
$this->moduleId = $moduleId;
}
public function submit(): void
{
// …
$this->closeModal();
}
}
2. The view always uses <x-modal-card> — never a raw <header> or
<footer>:
<x-modal-card class="w-[min(92vw,32rem)]" :title="__('…')" :subtitle="__('…')">
<form wire:submit="submit" id="my-form" class="space-y-3">
<x-input-field :label="__('…')" wire:model="amount"
:errorMessage="$errors->first('amount')" />
</form>
<x-slot:footer>
<x-button variant="primary" type="submit" form="my-form">{{ __('actions.save') }}</x-button>
<x-button variant="secondary-subtle" type="button"
wire:click="$dispatch('closeModal')">{{ __('actions.cancel') }}</x-button>
</x-slot:footer>
</x-modal-card>
<x-modal-card> gives you the header, the close button, consistent borders
and padding, and a <x-slot:footer> with its own top rule. Note
w-[min(92vw,32rem)] rather than max-w-* — that is how a modal's
responsive width is written here.
The button sits outside the
<form>and points at it withform="my-form": the footer is the form's sibling, not its child, and a submit button outside its form does nothing without that reference.
3. Opening, from anywhere, with the arguments mount() takes:
wire:click="$dispatch('openModal', {
component: 'partner.modules.new-version-modal',
arguments: { moduleId: {{ $module->id }} }
})"
The name is the component's alias in flat form. Avoid :: in it —
Livewire 4 runs the name through normalizeName() before looking it up, so an
alias containing :: never resolves.
4. Closing: $this->closeModal() from the server, or
wire:click="$dispatch('closeModal')" from the markup.
Why not roll your own: the backdrop, the focus trap, Escape to close,
returning focus to the button that opened it, and locking background scroll
are all in the package. A hand-built modal looks right and leaves a keyboard
user tabbing around the page behind it.
Reference files in the platform
resources/views/livewire/partner/modules/module-edit.blade.php— hero header + sticky tab nav + save bar.resources/views/livewire/partner/modules/notifications/notifications-index.blade.php— table + empty state + version picker.resources/views/livewire/partner/modules/notifications/notification-form-modal.blade.php— full modal form.
AI support
Apps that use artificial intelligence must declare it explicitly in their manifest through two fields:
aiSupport(boolean): does the app use AI?aiMode(enum): eitherown(the partner's own tooling) orplatform(Okta's unified AI engine).
Where to declare it in the platform
- When creating the app: an "AI support" card appears between "Basic information" and "Pricing" on
/partner/modules/create. - When updating versions: the same card lives in the version editor's "Overview" tab, so you can add AI support to a specific release without modifying the parent app. A declaration at the version level overrides the module-level declaration.
The options
own— your app manages its own AI providers (OpenAI / Anthropic / Gemini / ...) and keys. No additional approval from the platform team is needed.platform— your app consumes the centralAiManagerin okta-web. Declaring it IS approving it — there is nothing to wait for after that.
How platform actually gets approved
The runtime guard (EnsureAiPlatformApproved) asks two columns on the modules row in okta-web: ai_support and ai_mode = platform. It does not read your raw manifest, and there is no queue. Those columns are filled from your manifest on two paths:
| Path | When |
|---|---|
| Publish | The publish review that approved your manifest is the platform signing off on its aiMode — no second approval request |
| Push to sandbox | Immediately on push, with no production publish in the way |
In sandbox: declare it, push, and it works — with no platform-admin approval of any kind.
A sandbox drops the approval question entirely: the app is installed in no real entity, the data is nobody's, and the point of the environment is to find out whether the feature is worth submitting at all — so an approval decision that blocks the trying is a decision about a question nobody has asked yet. Even a revoke does not reach sandbox: if the platform team revokes, production stops and your sandbox keeps working.
The reverse does not hold: pushing to sandbox does not clear a production revoke. On a shared deployment your sandbox tenant and production read the same module row, so if a push cleared the flag any partner could take back access that was removed for cause, just by pushing to their own sandbox.
(This was broken: the sandbox install path never extracted the two columns, so an app declaring aiMode: platform and pushed to sandbox got a permanent 403, and the only way out was a full production publish.)
The Okta team can still revoke on abuse. A revoke stops calls immediately, and access returns on the next publish that passes a human review.
Reading the 403 when it comes
The refusal now carries a reason naming which of the two cases it is — they need opposite fixes:
{
"error": "ai_platform_not_approved",
"reason": "not_declared", // or "revoked"
"message": "..."
}
not_declared— your declaration was never recorded: checkaiSupport/aiModein your manifest and make sure the app was published or pushed to sandbox. Do not wait for anybody.revoked— the Okta team took access away: talk to them.
Headers: X-Ai-Approval-Required: 1 and X-Ai-Denied-Reason: <reason>.
Detailed reference
See ai-support.en.md for the full validation rules, manifest examples, and FAQ about cost and upgrading between own and platform.
AiManager usage examples
These examples apply to
aiMode=platformonly. Apps usingaiMode=owncall their providers directly (see the last sub-section).
Injecting AiManager
use App\AI\AiManager;
use App\AI\AiException;
class StudentReportsController extends Controller
{
public function __construct(private AiManager $ai) {}
public function summary(Request $request)
{
try {
$summary = $this->ai->summarize($request->long_text);
return response()->json(['summary' => $summary]);
} catch (AiException $e) {
return response()->json(['error' => $e->getMessage()], 502);
}
}
}
Laravel resolves AiManager automatically through AiServiceProvider (registered as a singleton). You can also use app(AiManager::class) or app('ai').
chat() — simple conversation
$reply = app(AiManager::class)->chat(
prompt: 'Summarize the following student report in 3 bullet points',
context: [
['role' => 'user', 'content' => 'Student report: ...'],
['role' => 'assistant', 'content' => 'Sure, let me read the report.'],
],
opts: [
'model' => 'gpt-4o', // optional — default picked by Okta's engine
'temperature' => 0.3,
'max_tokens' => 500,
'system_prompt' => 'You are a professional educational assistant.',
],
);
stream() — streaming response (for interactive UIs)
use App\AI\AiManager;
return response()->stream(function () {
$full = app(AiManager::class)->stream(
prompt: 'Explain the concept of factor analysis',
context: [],
opts: ['temperature' => 0.5],
onChunk: function (string $chunk) {
echo "data: " . json_encode(['text' => $chunk]) . "\n\n";
ob_flush();
flush();
},
);
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
In Livewire, use wire:stream or update a public property from inside the onChunk callback.
complete() — text completion (no chat history)
$completion = app(AiManager::class)->complete(
text: "In his essay, the student wrote: 'Education is the path to",
opts: ['max_tokens' => 50],
);
// result: "...progress and renaissance in modern societies, and therefore..."
summarize() — summarize a long text
$summary = app(AiManager::class)->summarize(
longText: $student->report_full_text,
opts: [
'max_tokens' => 300,
'system_prompt' => 'Summarize in one paragraph, focusing on strengths and weaknesses.',
],
);
translate() — translation
$ar = app(AiManager::class)->translate(
text: 'The student excels in mathematics and sciences',
toLocale: 'ar',
);
// result: "الطالب متفوّق في الرياضيات والعلوم"
Error handling
use App\AI\AiException;
use Illuminate\Http\Client\ConnectionException;
try {
$result = app(AiManager::class)->chat($prompt);
} catch (AiException $e) {
// Provider failure, quota exceeded, model unavailable, ...
Log::warning('AI request failed', ['error' => $e->getMessage()]);
return back()->with('error', 'Could not process your request right now. Try again later.');
} catch (ConnectionException $e) {
// Service unreachable (rare)
return back()->with('error', 'AI service is unavailable.');
}
Do not catch
PlatformAiNotApprovedException(HTTP 403). Let it propagate — theEnsureAiPlatformApprovedmiddleware will return a standard "platform approval pending" response to the user, along with theX-Ai-Approval-Required: trueheader. This is the platform's canonical approval-required UX, and swallowing the exception breaks it.
aiMode=own examples — partner's own tooling
If you picked aiMode=own, Okta plays no part in routing requests. You call your provider directly using its SDK. OpenAI example:
use OpenAI\Laravel\Facades\OpenAI;
$response = OpenAI::chat()->create([
'model' => 'gpt-4o',
'messages' => [
['role' => 'system', 'content' => 'You are an educational assistant'],
['role' => 'user', 'content' => $userInput],
],
]);
$reply = $response->choices[0]->message->content;
Or Anthropic Claude:
$response = Http::withHeaders([
'x-api-key' => config('services.anthropic.key'),
'anthropic-version' => '2023-06-01',
])->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-opus-4-7',
'max_tokens' => 1024,
'messages' => [
['role' => 'user', 'content' => $userInput],
],
])->json();
$reply = $response['content'][0]['text'];
Your responsibilities in own mode:
- Store API keys securely (env vars, KMS, ...)
- Manage usage quotas and provider billing
- Disclose to the tenant any data sent outside the platform
- Comply with privacy and data-protection policies
See ai-support.en.md for the full reference on aiSupport/aiMode validation rules and migration paths.
AI Agent with tool use
Apps that want AI that performs actions (not just answers) use the Agent with the concept of "Tools". You define tools as PHP classes, and the AI decides when to call them — for example, when a user asks "add 5 committees with 30 students each", the AI autonomously calls the exams.committees.add tool with the right arguments.
This feature requires
aiMode=platformdeclared in your manifest and recorded on the platform (by publishing, or by pushing to sandbox) — see "Howplatformactually gets approved" above.
Tool anatomy
Each tool in your app is a PHP class that implements the App\AI\Contracts\AiTool interface and lives under Modules/<ModuleStudly>/AiTools/. The platform auto-discovers them when the app loads — no manual registration required.
Full example: AddCommitteesTool in the Exams app
File: Modules/Exams/AiTools/AddCommitteesTool.php
<?php
namespace Modules\Exams\AiTools;
use App\AI\Contracts\AiTool;
use App\AI\Exceptions\AiToolException;
use Modules\Exams\Models\ExamCommittee;
use Modules\Exams\Services\CommitteeDistributor;
class AddCommitteesTool implements AiTool
{
public function __construct(
private readonly CommitteeDistributor $distributor,
) {}
public function name(): string
{
return 'exams.committees.add';
}
public function description(): string
{
return 'Creates new exam committees and distributes students across them '
.'according to the given settings. Use this tool when the user asks '
.'to add committees or distribute students into committees.';
}
public function parametersSchema(): array
{
return [
'type' => 'object',
'properties' => [
'committee_count' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 100,
'description' => 'Number of committees to create',
],
'students_per_committee' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 60,
'description' => 'Number of students per committee',
],
'exam_period' => [
'type' => 'string',
'enum' => ['first', 'second', 'final'],
'description' => 'The exam period (first/second/final)',
],
'distribution_strategy' => [
'type' => 'string',
'enum' => ['alphabetical', 'random', 'by_grade'],
'default' => 'alphabetical',
'description' => 'The distribution strategy',
],
],
'required' => ['committee_count', 'students_per_committee', 'exam_period'],
];
}
public function requiredScopes(): array
{
return ['exams.committees.write', 'education.students.read'];
}
public function handle(array $params): mixed
{
$count = (int) $params['committee_count'];
$perCommittee = (int) $params['students_per_committee'];
$period = (string) $params['exam_period'];
$strategy = $params['distribution_strategy'] ?? 'alphabetical';
$available = $this->distributor->countAvailableStudents($period);
$needed = $count * $perCommittee;
if ($available < $needed) {
throw new AiToolException(
"Available students ({$available}) is fewer than required ({$needed}). "
."Reduce the number of committees or students per committee."
);
}
$committees = $this->distributor->createAndDistribute(
count: $count,
studentsPerCommittee: $perCommittee,
period: $period,
strategy: $strategy,
);
return [
'created' => $committees->count(),
'total_students_assigned' => $committees->sum('student_count'),
'period' => $period,
'committee_ids' => $committees->pluck('id')->all(),
'message' => "Created {$count} committees and assigned {$needed} students successfully",
];
}
}
Embedding the chat UI in your app's page
<x-ai-agent
:title="'Committees Assistant'"
:placeholder="'e.g. Add 5 committees for the first period with 30 students each'"
:system-prompt="'You are a smart assistant for managing exam committees in the okta-exams app. Help the user create, distribute, and manage exam committees.'"
height="600px"
/>
How the flow works
- The user types: "Add 5 committees for the first period with 30 students each".
- The component POSTs to
POST /api/apps/ai/agent/stream. - The platform resolves the current app from
AppContextManager, auto-discovers tools underModules/Exams/AiTools/, filters them byrequiredScopes()against the installation's granted scopes, and exposes the survivors to the model. - The model responds with
{"tool": "exams.committees.add", "args": {...}}. - The platform executes
AddCommitteesTool::handle()and returns the result. - The component renders each step live (running → done) via SSE frames (
tool_call,tool_result,text,done,error).
Error handling
AiToolException: a recoverable error — the message is fed back to the model so it can adjust and retry. Use it for validation failures.- Any other throwable: aborts the entire agent turn and logs the failure on the
partner_installchannel.
Permissions
Every tool declares requiredScopes(). The agent loop filters out tools whose scopes the installation doesn't hold before showing the toolset to the model. This means a write-capable tool stays safe even when the tenant only granted read scopes.
Iteration cap
Each agent turn is bounded to 5 iterations. After the cap, the model is asked to produce a final summary instead of more tool calls.
Authoring tips
- Tool name: use the pattern
module.resource.action. - Description: write it like API documentation — the model relies on it to decide when to call.
- JSON Schema: be strict (enums, min/max). It's the only contract between the LLM and your code.
- Return shape: keep it simple (array/scalar). The model summarizes it back to the user.
- Validate early: throw
AiToolExceptionfor expected error paths so the model can retry sensibly.
Testing a tool locally
// tests/Feature/AddCommitteesToolTest.php
$tool = app(\Modules\Exams\AiTools\AddCommitteesTool::class);
$result = $tool->handle([
'committee_count' => 3,
'students_per_committee' => 25,
'exam_period' => 'first',
]);
$this->assertEquals(3, $result['created']);
Okta mobile app
The Okta mobile app shows the user a set of service cards. Your app can expose a service that appears as a card inside it. This is declared per version — a later version can opt in without touching the original app.
Where to enable it
Partner dashboard → app → version → Integration tab → "Services inside the Okta mobile app" card → toggle it on, then provide the settings.
The three modes — pick one per version
| Mode | What you ship | Where | Rendered as |
|---|---|---|---|
native |
Real Dart (Flutter) | okta_app/native/<entry>/lib/ |
A native UI inside the Okta app |
webview |
Web files | okta_app/webview/ |
A confined WebView |
external |
Nothing — you host it | An HTTPS URL of yours | A WebView on your URL |
native is the only mode that gives you a native UI, and the only one that can
reach device hardware through Okta.* calls. The other two go through the
browser permission model, which the platform does not mediate.
Settings you provide
| Setting | Description |
|---|---|
| mode | native (Dart code — a native UI), webview (web files in your repo) or external (a URL you host). |
| entry | native: a .dart file under okta_app/native/<entry>/lib/. webview: repo-relative path inside okta_app/webview/. external: full HTTPS URL. |
| min_contract | native only. The lowest host contract your code needs. |
| allowed origins | External only: HTTPS origins the page may be loaded from. |
| required scope | Optional. The card is shown only if the user's active role holds this scope (from the scopes granted to the version). Empty = shown to any role that can open the app. |
| pass role claim | Optional, external only. Adds the role claim inside the signed JWT. No database access either way. |
File structure (webview mode)
Put everything rendered in the mobile app inside a okta_app/webview/ folder at
the root of your app repo:
mobile/
├── README.md
├── manifest.json ← optional metadata for the mobile surface
├── screens/ ← entry files rendered in the WebView
│ └── dashboard.blade.php
└── assets/ ← css / js / images for those screens
Example entry: okta_app/webview/screens/dashboard.blade.php.
Policy (enforced)
Mobile services are confined to the dedicated okta_app/webview/ namespace:
- The
webviewentrymust be a repo-relative path insideokta_app/webview/only — nohttp(s)://scheme, no..traversal. Anything else is rejected at save/review time. - Mobile screens may not link, navigate, or redirect to any
platform/tenant page outside the
okta_app/webview/namespace. - This is also enforced at runtime by okta-web's
app.webviewmiddleware (the mobile surface is fenced to the/appnamespace). - In
externalmode you ship nothing here — you host the page, and isolation comes fromallowed origins+ zero data binding.
Mobile dashboard
okta-app's first screen is a dashboard, not an app list. The launcher has a tab of its own — Apps, in the bottom bar — and the dashboard shows what the installed apps themselves have to say: today's numbers, the queue, what just happened.
Your app can put a block on it. Every mode may — native, webview and
external alike — because a block is data, not a surface.
The portals get the same home
The student's and the guardian's portal homes are dashboards too, with the
portal launcher on its own Apps tab — the same shape the tenant home has. Your
block reaches a portal automatically wherever your app declares an audience
for it; nothing new to declare, and audiences narrows there exactly as it
does for roles — write ["guardian"] to brief guardians only.
Two things differ, both by design:
- The audience parameter carries the portal. Your provider's first
argument (and the
rolefield of the external POST) isstudentorguardianwhere a tenant call passes the role key. Same parameter, same meaning — who is reading — and a provider that ignores it briefs every audience the same figures, which is a fine default. - The portal is cross-tenant, so your block appears once per school. A guardian with children at two schools that installed your app gets two blocks — the figures differ, and okta-app labels each with its school's name. You do nothing for this: your provider runs once per school in that school's context, exactly as it always has.
The rule everything else follows: data, not design
You return figures and labels; okta-app draws them, identically for every app. That is not a limitation to work around — it is the reason a dashboard carrying five apps still reads as one screen. A block that brought its own layout would be a partner's card sitting inside our dashboard, and the reader's first question — who is telling me this, and how old is this number? — would get five different answers.
It is also why a block costs nothing: no mini-app compile, no sandbox, no contract version. Five blocks are five array returns, not five interpreters.
Declaring it
Inside mobile in the manifest:
"mobile": {
"supported": true,
"mode": "native",
"entry": "main",
"dashboard": {
"enabled": true,
"title": "حضور اليوم",
"title_en": "Today's attendance",
"provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
"audiences": ["tenant-admin", "teacher"],
"cache_ttl": 300
}
}
| Key | Required | Description |
|---|---|---|
enabled |
Must be true. Do not ship a disabled block — omit the whole thing. |
|
title / title_en |
(one of) | The block's heading. Without it the card is headed by your slug, which tells the person holding the phone nothing. |
provider |
embedded | A class under Modules\ implementing MobileDashboardProvider. |
endpoint |
external | An https URL that receives one signed POST. |
audiences |
— | Role keys. Narrows, never widens (see below). |
cache_ttl |
— | Seconds. Clamped to 30..3600. |
portal_scope |
— | per_tenant (default) or combined — how the block reads on a portal home when one person meets several schools. |
mode |
— | Who draws the inside: data (default — the host draws your figures) or native (your Dart draws it — see below). |
entry |
with native |
The card's Dart entry file under okta_app/native/<package>/lib/. |
min_contract |
with native |
The okta-app host contract the card needs. Floor 21; lower values are clamped up. |
💡 Instead of editing manifest.json by hand: the MCP tool
set_mobile_dashboardwrites the block straight onto an editable version and runs the publish checks immediately;get_mobile_surfacereads it back. The repo sync (sync_from_manifest) carries it too.
The transport follows integrationType and is not declared. An embedded app
names a class the platform calls in-process; an external one names a URL the
platform signs one request to. Declaring both — or the wrong one — is rejected
at publish, because a second switch for something the manifest already answers
means the first app to set them inconsistently publishes a block that never
appears.
Embedded: implement the contract
namespace Modules\OktaHdor\App\Services;
use App\Services\PartnerApi\Contracts\MobileDashboardProvider;
final class DashboardProvider implements MobileDashboardProvider
{
public function dashboard(string $roleKey, string $locale): array
{
$today = Attendance::query()->whereDate('taken_at', today());
return [
'stats' => [
[
'label' => $locale === 'en' ? 'Present' : 'حاضر',
'value' => number_format($today->clone()->where('status', 'present')->count()),
'tone' => 'success',
],
[
'label' => $locale === 'en' ? 'Absent' : 'غائب',
'value' => number_format($today->clone()->where('status', 'absent')->count()),
'tone' => 'danger',
],
],
'rows' => [
['title' => 'Year 1', 'subtitle' => 'A', 'value' => '98%'],
],
'open_label' => $locale === 'en' ? 'Open register' : 'فتح السجل',
];
}
}
It runs inside your own module context, reading your schema with your credentials exactly as your own pages do. Scope every query to the current tenant.
External: receive a signed POST
POST <endpoint>
X-Okta-Timestamp: 1755859200
X-Okta-Signature: HMAC-SHA256( "<timestamp>.<body>", install_signing_secret )
{"tenant_id": 42, "role": "tenant-admin", "locale": "en"}
Verify the signature before answering, and answer in the same array shape. The timeout is 6 seconds; past it you are treated as unreachable.
Nothing identifying the person is sent. A figure on the dashboard is about the school, and you do not need to know which member of staff is looking at their phone to count today's absences.
The payload
[
'title' => "Today's attendance", // optional — overrides the manifest title
'stats' => [ ['label' =>, 'value' =>, 'caption' => ?, 'tone' => ?] ],
'rows' => [ ['title' =>, 'subtitle' => ?, 'value' => ?, 'tone' => ?] ],
'open_label' => 'Open register', // optional — omit to show no button
'message' => null, // a note shown with the figures
]
value is a string you have already formatted. Percentages, currencies,
ratios and counts share no formatter, you know which you are producing, and a
numeric field would force the client to guess — or carry a format vocabulary
that grows with every partner.
tone is a meaning, not a colour: neutral | success | warning | danger | info. The host resolves it to a palette that matches the rest of the screen in
both light and dark; a hex you pick would match neither.
Limits are enforced: 6 stats, 8 rows, 120 characters per string. The excess is truncated silently — a block that takes the whole screen is not a block.
Keys outside the vocabulary are dropped, never passed through. An unrecognised key that reached the client would become a de-facto part of the contract the first time a partner relied on it.
Narrowing by role: it narrows, never widens
audiences are role keys that restrict the block to some of the people who
can already see your app. The dashboard is built from the catalog the user
already receives, so an app hidden from a role cannot brief that role through
the back door. Leave it empty for every role that can see the app.
Failure is a state, not an exception
Throw when you fail. The platform catches it, logs it, and shows your block
offline — or with its previous figures marked stale if any were cached.
Do not return an empty array instead. Empty is indistinguishable from "there is genuinely nothing today", and would show an operator a confident zero on a morning your query was broken.
The platform also keeps a day-long fallback copy to answer with when you cannot: yesterday's attendance marked "not current" is more use to someone standing at a school gate than an empty box.
Drawing the card yourself — mode: "native"
Everything above holds — and then an embedded app can go one step further and draw the inside of its card in real Dart, through the same source-on-device runtime its mini-app already uses:
"dashboard": {
"enabled": true,
"title": "حضور اليوم",
"title_en": "Today's attendance",
"provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
"mode": "native",
"entry": "okta_app/native/card/lib/main.dart",
"min_contract": 21
}
The host keeps the frame. The corners, the header with your app's name and icon, the sync chip and the "open" button stay the platform's; your widget owns the inside of a box whose edges it cannot reach — including its height: the card is clipped at 320pt, not scrolled, because a scrollable inside a dashboard that itself scrolls steals the drag from the reader.
provider stays required, and that is the design, not a leftover. The
figures it returns are the card an older phone draws — and the card this
phone draws the moment anything goes wrong. Every failure lands there,
silently, in the same session: source that will not compile, a widget that
throws while building, a first frame that does not arrive within 500ms of the
runtime handing over, a device whose host contract is below min_contract.
There is no partner-visible error state on the home screen — the reader gets
your data card, and the home screen never breaks because of a partner.
Test your card by making it throw on purpose: what you should see is your own
figures.
entryis a package of its own. It lives underokta_app/native/<package>/lib/like every native entry, and should be a dedicated package (okta_app/native/card/) rather than a second file inside your app's package — delivery slices one package per entry, so a card entry inside the app's package would download and compile your whole app to draw one card.min_contractis required and 21 is the floor — 21 is where the card runtime shipped, so lower values are clamped up. A device below it never mounts the card and shows the data card instead; that refusal is deliberate and invisible, not an error.- The sandbox is the mini-app's sandbox. Same
Okta.*API, same isolation, same theme. One symbol exists for cards specifically:Okta.openMiniApp()opens your full app — the same act as the host's own "open" button. Deep navigation stays in the app; a card is a summary, not a screen. - Portals multiply the card, not its state. A guardian with children at two schools gets two cards, compiled and mounted separately, sharing no state, no tenant and no delegate — the same per-school isolation the full mini-app has.
- Embedded only. A native card compiles from your signed source bundle,
and an external app ships no code —
mode: "native"is refused at publish; keepmode: "data"with your endpoint.
set_mobile_dashboard writes all three keys and runs these checks at dev
time; the manifest schema and the publish gate repeat them.
Before you publish
- Your block appears only to people who can already see your app in the catalog.
cache_ttlis clamped to 30..3600 at both ends — the published manifest shows the number that will actually be used, not the one you wrote.providermust live underModules\. The platform constructs this class by name, so anything outside your module namespace is a manifest choosing a platform internal to instantiate — and theinstanceofcheck is too late, because the constructor has already run.- Nothing in a block is tappable except one button that opens your app. No links, no HTML, no images.
Display screens (Okta Screen)
A phone is opened by a person; a screen is opened by nobody. A television on the classroom wall, a panel in the school lobby, a display by the complex's gate — a machine that runs all day with no user in front of it, driven by a separate app called Okta Screen (Android TV / Google TV, and Windows). It signs in by pairing only: no account and no password, just a code the organisation's administrator mints from the device registry and types on the screen once.
What sets a screen apart from a phone is where it stands, not who holds it: at pairing the administrator picks its place — the whole organisation (a lobby, a corridor) 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)andwatchedFirst(...)— tell you when focus enters and leaves, so you can enlarge or tint your own tile.OktaFocus.reachable(child)— the arrows reach it, nothing is pressed: for a row in a long list the ring should travel through rather than skipping a block of content.
The host draws the focus ring, in the shell's colour, with its width
always laid out so gaining focus never nudges the row your tile sits in. You
do not draw it and cannot hide it: a board on a wall with no visible ring is
a board nobody in the room can navigate. Use watched to add your own
emphasis on top of it, never instead of it.
autofocus: true on a stock button still does nothing, because the
bridged button has no such parameter to arrive through. OktaFocus.first is
the answer.
Focus, FocusNode, FocusTraversalGroup, KeyboardListener and
PopScope do not exist here at all — each fails with a compile error
(Could not find declaration), not a runtime one. Two things follow:
- A bare
GestureDetectoris never reachable by the remote. A widget becomes a traversal target by owning aFocusNode, and there is no way to give it one withoutFocus. Touch and mouse on Windows still work on it, and a TV remote simply cannot reach it. Wrap it inOktaFocus.item(or put the action on aListTile) — never leave an action on a bareGestureDetectoron the screen surface. - You could not swallow the menu key even if you wanted to, and
OktaFocusdid not change that. Intercepting raw key events is structurally impossible from your side, not merely discouraged: the only key that reaches your code is select, and noKeyEventobject crosses into the sandbox at all. The arrows keep moving focus, and the menu key (Menu on the remote, or F1/Escape on a keyboard) and the back key always belong to the host and open its menu (the launcher, reload your app, settings) — the one way out of a lone app running in kiosk mode (auto_launch), where there is no "back" because there is nothing else to go back to.
And a passive screen with no buttons is a perfectly legitimate shape (a roll call, a notice board): the menu key keeps working because the host holds a fallback focus whenever nothing on your page holds one.
No MediaQuery and no LayoutBuilder — neither is available (the first
is not bridged, the second fails to compile like the rest above). Your
screen therefore cannot measure its own canvas at all, so design
straight to the fixed footprint: a 1080p television at ~320dpi reports a
logical size of about 960×540, not 1920×1080 — which is why the examples
above use type larger than you would on a phone (36pt is not an arbitrary
choice).
auto_launch describes your intent, not a guarantee. The screen's own
administrator holds a device-level switch ("always show the home screen")
that overrides auto_launch for every app on that particular screen, so
it always opens onto the grid of installed apps even when your app is the
only one declared there. Do not assume your app will simply appear with no
interaction: build its first screen so it reads fine whether the user
arrived there automatically or by tapping a tile on the grid — which is
exactly how it gets opened from the simulator and during review anyway.
Designing the screen surface — sizing, layout, and the back button
A screen is seen from across a room by many passing eyes, not held close by one — that flips the usual phone-design priorities:
- Design for the real baseline of roughly 960×540 logical px (a 1080p television at ~320dpi), not a phone canvas scaled up. Build for this footprint directly rather than designing a phone layout and stretching it by a fixed factor — proportions that read as balanced on a phone go either empty or crowded on a screen with a completely different, wide-and-short 16:9 aspect ratio.
- Leave a safety margin from the edges (overscan): some real TV panels — older ones especially — physically crop a few pixels off every edge. Never place text or an interactive element flush against the screen's edge; leave at least 24-32dp of margin around the entire content area.
- Type much larger than you would ever use on a phone — it is read from
the back of a classroom or a lobby, not arm's length. 36pt, used in this
chapter's examples, is a reasonable floor for body text, with headings
well above it. Use strong contrast between text and background (not close
shades of grey) — read colours through
OktaPalette.of(Okta.isDark())rather than hard-coding them, so your app follows the screen's own light/dark mode, which its administrator sets from that device's own settings, not from your app. - Few, large interactive elements — not a dense small grid: navigation
is entirely direction-key input on a remote, so every extra tile in a grid
is one more press for someone with no touch to jump straight to it.
Prefer a short list of large cards over a crowded grid of small ones. The
focus ring is drawn for you — by Flutter on stock widgets, by the host on
OktaFocustiles — so do not replace it with a faint hand-rolled highlight that is hard to make out from across a room. - Keep information density low, generally: a screen is usually a passive display (a roll call, a notice board, a live tally) glanced at in passing, not a dashboard someone studies closely. One clear message or one readable list beats four stat cards, a table and a chart crammed onto one page.
The remote's back button belongs to the host, not your app. The physical "back" key (Back on a TV remote, or Escape on a Windows keyboard) is intercepted entirely at the host level before it ever reaches your own widget tree, and its behaviour is fixed by how many apps are installed, not by your code:
- A single installed app (kiosk): back is swallowed completely — nothing happens, deliberately, so a passive display can't be nudged off its state by a stray press. The only way out is the menu key (see the previous section).
- More than one app: back sends the user straight to the app grid (the launcher) — your widget tree is never consulted and gets no notification of this at all.
So do not build internal navigation (a list drilling into a detail view,
say) that depends on the physical back key to go up a level — that event
never reaches your code in the sense phone apps usually assume. If your
screen needs a way out of a detail view back to a list, put a visible
"back" control on the screen itself — a stock button or a ListTile, not a
bare GestureDetector (see the remote-reachability rule above), placed
first in reading order, or carrying OktaFocus.first — and drive
it from your own widget state (setState), not from Navigator.
Trying the screen on the virtual device (the simulator)
You do not need a television to try it: the portal's «Virtual device» (the
app's tab, or /dashboard/simulator) mounts the very same screen surface in
the browser — the same dart_eval engine your code is compiled on by a
display, and the same source bundle from your branch.
- The «Display (Okta Screen)» surface appears in the surface picker when
the version declares
mobile.screen, and is selected automatically for an app that has no phone surface at all. - The place comes first: one row per declared place (organisation screen / classroom screen), each naming its own entry file. The simulator compiles the chosen place's file alone — exactly as a display does — so an app serving both places is tried twice, one package per place.
- The frame is a 960×540 logical television (dpr 2, no safe-area insets) — the size an Android TV really reports, not a phone turned sideways. The device panel offers other sizes for comparison, but the layout is judged at this one.
Okta.context()exactly as a display writes it, key for key:screen(tenant|section), for a classroomsection_idandsection_name, and an emptyrole_idbecause nobody signs in to a screen. The classroom is simulated (sim-section/ «Simulated classroom») — from the «Persona» panel you can type a real sandbox section's ULID and name if you want to follow its data. Changing the place or the section is a relaunch, not a repaint, because on a display too the context is injected at startup.- Branch source only: «Installed on sandbox» is disabled for a screen — no display is paired to the sandbox to download its bundle from.
- The remote is your keyboard: arrows and Tab move focus, Enter is OK. What an arrow cannot reach in the simulator, a remote cannot reach on the television either (see the D-pad section).
- A limit you must know — data is NOT pinned to the classroom here.
/api/appscalls in the simulator go through the sandbox seat (an admin account), not a screen device token, soeducation/studentsreturns the whole school, not the classroom. On a real display the server pins the section onto the context (sectionScopeId) and nothing else gets through — a server guarantee that does not depend on your code, and one that can only be tested on a paired screen. In the simulator, filter bysection_idyourself if you want to see what the classroom will see. - From MCP:
simulator_start {slug, surface: "screen", screen_scope: "section"}(or"tenant"), thensimulator_screenshot/simulator_ui/simulator_tapas for any session — the tool's reply names the place, the simulated classroom and the data limit above.
When your app fails on a screen — the error code and platform reporting
A screen nobody stands behind must neither go silently white nor show a red
error tree to a whole classroom. So when your app fails — a compile error, an
exception while building, a load timeout — the display shows a status screen
carrying an error code of the form MA-XXXXXXX (MA = mini-app, then a
fingerprint derived from the error's type and text — the same algorithm as
okta-app's codes, so the same failure yields the same code on every device).
The operator reads it off the television and reports it to you.
- The code reaches the platform on its own: the display reports the
failure to okta-web once per code per session (
POST /api/device/client-errorswith the paired device's token), with the message, the stack, your app'smodule_slugand the device's identity — so the failure is attributed to your app, not to the screen. - You read it on your app's «Errors» page in the portal, or through the
MCP tools:
recent_errorsfor the latest,app_errorsfor the triage list, andget_errorwith the code the operator read — from a code on a television to the full stack with nobody in between. - Repeats fold: failures with the same fingerprint land on one row with
a counter (
occurrences_count), so a hundred screens showing the same failure are one row, not a hundred reports. - What is not reported: an app that runs and shows something wrong is invisible to the platform — the report is for failures that prevent drawing. The way out on the device itself is the host menu (menu key → reload the app).
From the editor and MCP
- Simulator: the «Display» surface on the virtual device (section above),
and from MCP
simulator_startwithsurface: "screen"andscreen_scope. - Editor: the "In-Okta-app services" card → Display screens section — a checkbox per place that reveals its own entry file and its own title, and visible even with the phone surface switched off.
- MCP:
set_mobile_screenwrites the block (a full replace, ornullto remove it) and runs the publish checks early;get_mobile_surfacereads it back and prints a line per place with its entry. - Repo sync (
sync_from_manifest) carries it exactly as it carriesdashboard.
native mode — a mini-app written in Dart
You write real Dart — no JSON, no DSL. The Okta app downloads the source,
compiles it on the device (once per published version, then caches it) and
renders the widget your main() returns, with the full platform identity.
No compiled artifact ever leaves your repository. On publish, okta-web
reads every .dart file under okta_app/native/<entry>/lib/ into a single
signed source bundle, so what is reviewed is exactly what runs.
Layout (shipped ready in the boilerplate)
okta_app/native/main/
├── pubspec.yaml ← pins okta_miniapp — do not bump the ref
├── analysis_options.yaml
├── lib/
│ └── main.dart ← entry point: Widget main()
└── tool/
└── validate.dart ← the same gate CI runs
The entry is okta_app/native/main/lib/main.dart — a .dart file under
lib/ only, no ... Enforced both at save and at publish.
The smallest thing that works
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
/// Okta calls this to obtain your root widget.
Widget main() => const HomeScreen();
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
// The scalars, not `Okta.context()`: that returns a decoded **map** with
// snake_case keys, so `ctx.tenantId` does not exist and `ctx['tenantId']`
// is a missing key — and reading a missing key is the nastiest trap in
// this runtime (below).
return Scaffold(
appBar: AppBar(title: const Text('My app')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Tenant: ${Okta.tenantId()} Role: ${Okta.roleId()}'),
ElevatedButton(
onPressed: () => Okta.toast('Hello'),
child: const Text('Say hi'),
),
],
),
);
}
}
The host contract — package:okta_host
Okta.* is the only way out of the sandbox. The current contract is 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 throughscanNfc. It never looked like a fault: the scan succeeded and the lookup missed, so the app reported an unregistered person. Fixed in the host; the two surfaces agree now.
The same applies to the WebView bridge: the nfc event now carries the
canonical form where it used to carry UPPER-case.
The embedded scanner: cameraScanStart arms, cameraPreview opens
Okta.cameraScanStart() arms the camera; it does not open it. The lens
opens when you mount Okta.cameraPreview(size) in your tree, and the preview
is not an optional display — it is the source of the reads. An app that
arms the scanner and never mounts the preview gets nothing from
cameraScanReads(), ever.
The order:
cameraScanStart() → mount cameraPreview(size) → drain cameraScanReads()
And true means "armed", not "open": what is settled before it returns is
the OS camera permission, which is what false reports. Everything after that
is decided by mounting the preview.
This changed in the host.
cameraScanStartused to try to open the lens itself, and it failed on every device: the package refuses to start before the preview widget is built, and the preview was only built after a successful start — a closed loop that made the embedded scanner answerfalsefrom contract 15 onwards. If you tried it once and gave up, try it again with the order above.
A gate that works with no network — the roster and the fingerprint (contract 20)
GET /tool-keys/resolve asks the platform "whose card is this?" — and needs a
network. At a school gate at 7am, the moment you most need an answer is the
moment the network is what failed.
So the roster is downloaded ahead of time:
GET /api/apps/tool-keys/students scope: tool_keys.links.read
{
"students": [
{
"student_id": "01J2X…",
"full_name": "…",
"grade_id": "01J2A…",
"section_id": "01J2B…",
"fingerprints": ["9f2c1d…", "4b70aa…"]
}
]
}
The rows carry fingerprints, not keys, and that is not a restriction — it is what made the roster possible at all. An NFC tool key is the card's UID, so a roster of raw keys is the means to write a card that opens the gate as any pupil on it. A fingerprint matches; it does not manufacture.
So do not compare a scan to the roster directly. Put it through this first:
final reads = await Okta.toolKeyReads();
for (final read in reads) {
final fp = await Okta.toolKeyFingerprint('${read['key']}');
if (fp == '') {
// Could not fingerprint — no keying material cached and no network to
// fetch it. This is NOT "card not registered"; do not treat it as one.
continue;
}
final student = rosterByFingerprint[fp]; // the roster you downloaded
…
}
Three properties you can rely on:
- Stable for one card at one school — the same value every time, and the same whether an NFC or a UHF reader produced the key.
- Different at another school for the same physical card. Never store a fingerprint from one organisation and compare it at another.
- The keying secret never reaches your app. The host holds it and fingerprints on your behalf. If it reached you, every four-byte UID could be generated and the roster turned back into the cards — which is the thing the fingerprint exists to prevent.
The empty string is not "unregistered".
toolKeyFingerprintanswers''when it could not fingerprint — no keying material yet, or a portal session with no organisation. An empty fingerprint compared against the roster matches nobody, so at a gate it looks exactly like a pupil who was never enrolled. Check for it explicitly and tell the operator the device could not verify, rather than that the pupil was refused.
A student with no card appears in the roster with an empty list rather than being left out: "not enrolled yet" and "not at this school" are different answers, and a roster of only card-holders gives both the same silence.
Declare minContract: 20 if you call toolKeyFingerprint. It is a new
symbol in an injected library and your app compiles on the device, so calling
it against an older host dies with Cannot find static method, naming a file
you never wrote. The roster itself is an ordinary HTTP call and needs no floor.
Note for existing apps: this roster sits under the same
tool_keys.links.readscope your app already holds if it usesresolve. No new scope and no re-consent from organisations — but its meaning widened: it was "confirm a card handed to you", and now also covers "download the list of who holds one".
Calling above the device's contract does not fail — it poisons the session
Every row with a bold contract above is backed by an external function the host registers. On an older host it is not registered, and invoking it does not return an error — it corrupts the interpreter for the rest of the session: later calls fail with nothing connecting them to the cause, and you hunt a bug in code that is fine.
Two answers; pick one per call:
- Declare
minContractequal to the highest number you use, so Okta shows "update the app" instead of running you on a host that cannot serve you; or- Guard at the call site:
if (Okta.contract() >= 11) { … }, with a fallback.The scalars,
get/post,toastandscan*need no guard — they have been there since contract 1.
The floating bottom bar — OktaTabs.floatingBar (contract 18)
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_kitwithCannot find static method, naming a file you never wrote. DeclareminContract: 18, or guard withOkta.contract() >= 18and fall back to the oldbottomBar.
flutter analyzewill flagOktaandpackage:okta_hostas undefined — that is expected. The library is injected by the runtime at compile time and is not a real pub package. The authoritative gate isflutter test tool/validate.dart.
What Okta.get / Okta.post return — and how to consume it
Every HTTP call returns an OktaApiResponse: three fields and one derived
getter.
| Field | Type | What it holds |
|---|---|---|
status |
int |
The HTTP code — and 0 when the call never reached a server (network down, or a path the allow-list refused) |
body |
dynamic |
The decoded JSON body — or the raw string when it is not JSON |
error |
dynamic |
A transport/permission failure description; null when none occurred |
ok |
bool (derived) |
error == null and status in 200–299 |
Checking ok is enough for the happy path: it already combines "no transport
failure" with "a success status", so you never need to write
status >= 200 && status < 300 yourself.
final res = await Okta.get('/api/my-app/students');
if (!res.ok) {
// status == 0 means the call never reached a server — do not show "the
// server said…" there, because the server said nothing.
Okta.toast(res.status == 0 ? 'Could not connect' : 'Server said ${res.status}');
return;
}
// body is dynamic — keep it that way. See below.
final dynamic payload = res.body;
final dynamic rows = payload['data'];
if (rows is List) {
for (final dynamic row in rows) {
final dynamic name = row['full_name'];
// …
}
}
The indexing above assumes your endpoint returns a JSON object — it is your endpoint, so that assumption is yours to make. If it answers with something that is not JSON,
bodyarrives as a raw string and indexing it with a String key fails at runtime. You cannot guard withif (body is Map): that guard promotes the receiver and walks you into the same indexing trap.
body is dynamic on purpose, and that is not a detail. If you "tidy it
up" by giving it an explicit Map type:
final Map data = res.body; // ← don't
final rows = data['items']; // Cannot use variable of type String as index
// to map of type <int, Color>
you walk straight into the Map-indexing trap described in the dart_eval list
above, at compile time. The field arrives dynamic from the host for
exactly this reason: leaving it alone is the correct thing to do, not the lazy
one.
error versus status: error is for everything below HTTP — the
network dropped, or the allow-list refused the path so nothing was ever sent.
A server that answers 4xx/5xx arrives with error == null and the code in
status. Both make ok false, but they are different failures to a user: one
is "no connection", the other is "the server refused".
Okta.uploadFile does NOT — it returns a decoded map, not a class. Read it
with ['status'], ['body'], ['file_name'] and ['error'] (note the
underscore in file_name), and it is null when the user cancels the
picker, so check for null before you touch the keys. There is no .ok here
and no .fileName.
Why is this different from
Okta.get? BecauseOktaApiResponseis named byOkta.apiitself, and every app callsapi, so the class always resolves. An injected library may not name a class your app never reaches: dart_eval resolves such a class only when the source pulls it in, so for an app that never uploads a file the name does not exist — and the failure lands on the injected library, naming a file the partner never wrote:CompileError: Unknown type OktaUploadResult. That is whyuploadFile,context,locationandpreciseLocationall return maps.
final dynamic up = await Okta.uploadFile('/api/my-app/attachments');
if (up == null) return; // the user cancelled
final dynamic status = up['status'];
if (status is int && status >= 200 && status < 300) {
Okta.toast('Uploaded ${up['file_name']}');
}
Validate locally — the same gate CI runs
cd okta_app/native/main
flutter pub get
flutter test tool/validate.dart
This compiles your code against the exact device runtime, so anything outside the supported subset fails in CI rather than on a user's phone.
And pub get itself may refuse before anything reaches CI.
pubspec.yaml carries a ceiling on the Dart SDK version (not Flutter's —
see the environment: comment in the file itself; pub does not enforce the
Flutter bound on the root package, measured rather than assumed) because
okta_miniapp hand-writes class $Container implements Container, and
Flutter 3.41 added a member (Container.isAntiAlias) the pub.dev release
does not know about. A newer Flutter is refused by pub get with one line
naming the version to install — not a false "Got dependencies!" followed by
a compile failure later.
The supported dart_eval subset — read this before you write
The device runtime is dart_eval + flutter_eval, which support a subset
of Dart/Flutter. The list below is not a style preference — every item breaks:
-
Host calls through the static
Okta.*facade only — never top-level host functions. -
No
State.mounted— it is not bridged. CallsetStatedirectly. -
Callbacks must be closure literals, not method tear-offs:
onPressed: () => _doThing(), neveronPressed: _doThing("Cannot box Function"). -
Never pass
cond ? null : closureto a callback slot. Pass an unconditional() => …and guard inside the method (if (_busy) return;). -
Index JSON on a
dynamicreceiver — never through aMap-typed one.body['key']compiles and dispatches correctly whilebodyisdynamic. Give that receiver a raw-Mapstatic type — anis Mapguard promotes it, or you annotate a variable, parameter or fieldMap— andoperator[]switches to static resolution. dart_eval 0.8.5 then binds rawMapto an arbitrary bridged instantiation (Map<int, Color>, MaterialColor's swatch shape) and rejects your String key at compile time:Cannot use variable of type String as index to map of type <int, Color>. The<int, Color>never comes from your data — don't go hunting for it. Keep JSONdynamicend to end (final dynamic v = body['key'];) and guard on the result (v is List), never on the receiver you are about to index.is Listpromotion is fine — onlyMapis affected. -
No nested loops. dart_eval 0.8.5 throws a
RangeErrorat compile time when a loop's body — directly, or through a method it calls — contains another loop. Flatten hierarchical data into one flat list first (on the server, ideally), then run ONE loop over it with a loop-free row builder. -
Buttons:
ElevatedButton/TextButtononly —FilledButtonandOutlinedButtonare not bridged. -
Layout:
Row/Column(+Expanded).WrapandAlignmentDirectionalare not bridged — build grids N-per-row withRow+Expanded, and reach forDirectionality(notAlign+AlignmentDirectional) for RTL. -
Always pass
flex:toExpanded/Flexible. The bridge does not apply the parameter's default, so an omittedflexarrives asnulland is cast toint—type 'Null' is not a subtype of type 'int', thrown while the widget builds. WriteExpanded(flex: 1, child: …). The same holds for any bridged widget whose optional parameter is a non-nullableint. -
BoxDecorationcarries onlycolorandboxShadow.borderRadius,gradient,imageandshapesit in the bridge commented out, and an argument the bridge does not declare is silently discarded rather than rejected — it compiles clean and the value simply never reaches Flutter. SoBoxDecoration(borderRadius: …)paints square corners with no error anywhere, andvalidate.dartcannot catch it because nothing fails. Round withClipRRect(borderRadius: …, clipBehavior: Clip.antiAlias, child: ColoredBox(…)). -
BoxDecoration(border: …)does not compile at all. The parameter is declared, typedBoxBorder— but the bridge never declaresBorderas a subtype of it, and the compiler knows only what the bridge declares. An ordinaryBorder.all(…)fails withCannot assign argument of type Border to parameter of type BoxBorder. Draw a hairline as a 1px box:Container(height: 1.0, color: …).Those last two are the same trap from opposite ends: an argument the bridge does not declare is dropped without a word, while one it does declare is type-checked without mercy.
-
No arbitrary
Icons.*constants — use text or labels instead. -
Prefer simple
Map/Listshapes over deep generics across the host bridge.
When your change reaches the device — and when the old copy stays
The Okta app compiles the source on the device once, then caches it. The key is not the contents of the bundle — that is the whole point. It has four parts:
| Part | Moves when |
|---|---|
slug |
a different app |
entry |
a different entry package under the same slug (see "Several entry packages" below) |
payloadVersion |
the module row's updated_at moves — and every install carrying a new commit moves it |
runtimeSignature |
a library the host injects changes, i.e. with a new Okta app release |
On sandbox: push, then reinstall — do not bump the version
You do not need a new version for every change. The loop is:
- push your code to the version's branch;
- reinstall the same version on sandbox.
The install re-resolves the branch's commit every time (the commit_resolve
step), so okta-web receives a different commit, the module row goes dirty,
updated_at moves, payloadVersion changes, and the device's cache misses and
recompiles. install_on_sandbox accepts any existing version, not only
editable ones.
When the old copy really does stay
When none of the four moved. In practice there is one case: you reinstalled without pushing — same commit, nothing dirty, so the source is not re-pulled and nothing recompiles. That is correct rather than broken: there is nothing new to deliver.
This misleads more than it blocks. No error, no warning — you open the app, see exactly the old behaviour, and conclude your fix did not work. So the first thing to check is: did your push actually land on the branch the version points at? Not the code.
The bundle is built compressed at install — and rebuilt when it goes stale
Since compression was enabled the bundle is no longer built per launch. okta-web builds it once at install for each native entrypoint, gzips it and stores it, so a launch serves a file instead of walking your tree again. The Okta app downloads it, decompresses it and runs it.
The file is no longer frozen. It used to carry min_contract and
capabilities as they read at install time and not move until you reinstalled —
and that gave partners the worst debugging day there is: you publish a change,
the version number moves on every device, and the old code keeps running behind
it looking perfectly current. okta-web now compares the file's age, on every
launch, against the tree it can actually deliver: once your new code has
landed on the platform, one rebuild fires and the fresh file is served. And
while a publish's code download is still in flight (or has failed), devices
keep being offered the previous version — its number and its file together —
never a new number wrapping old code.
What the portal shows is what devices receive on their next open — no manual reinstall for a manifest that moved.
Your normal loop is unchanged: push, then reinstall when the files change (pulling your source is still what an install does); a manifest-only edit now arrives on its own.
And on production
Tenants receive what is published. There a new version is the mechanism — not because caching works differently, but because nobody reinstalls on their behalf.
Publishing is two steps under the hood: the platform records your version, then downloads your code with a one-hour token. Devices are only ever told about the tree that actually landed — and a stalled download no longer stays stalled: okta-web retries it automatically with a freshly minted token, from the first device launch that meets the wedge and from a scheduled sweep every ten minutes. So "the portal says 1.4.0 but phones still offer 1.3.0" for a short while after publishing is the system being honest, not your update being lost; if it persists, open the module's status — it carries the exact pull error.
Conversely, an Okta app update moves
runtimeSignatureand recompiles your app on every device without you publishing anything.
This misleads more than it blocks. There is no error and no warning — you open the app, see exactly the old behaviour, and conclude your fix did not work, so you start undoing it and trying other solutions to the wrong problem. In fact your fix never reached the device at all.
If you see old behaviour after a change you trust, check the version number before you check the code.
min_contract — the host-contract floor
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
nativeonly; 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.capabilitiesin a new manifest. The key is still ACCEPTED so that no already-published app fails its next publish, but nothing reads it: it is not normalised, not carried to the device, and not shown on the install screen. - Every device call simply works.
Okta.scanNfc(),Okta.scanBarcode(),Okta.toolKeyStart(),Okta.location(),Okta.preciseLocation(),Okta.uploadFile(),Okta.openDocument()and storage need no declaration and raise no platform prompt. - The operating system still asks. Android and iOS show their own runtime dialog the first time your app touches the camera, location or NFC, and the person manages it in device settings. That was never part of this gate.
Okta.hasCapability()/Okta.requestCapability()still compile and now always answertrue. They are kept because they are compile surface — a published mini-app that calls one would fail to compile ON THE DEVICE if the symbol vanished — so existing guards keep working and always take the granted branch. Write no new guards against them; the replacement re-uses these two names.- There is no "
minContract≥ 13" rule any more. DeclareminContractfor host features you actually depend on, nothing else. - A refusal is still not an exception. A device call can still answer
null/false/ a negative accuracy — absent hardware, an OS prompt the person declined, a reader that never paired. Keep checking return values; that discipline never depended on the gate.
The host contract moved to 19 for this. A device still on 18 or below enforces the OLD gate, and an app written for the ungated world is refused there silently —
scanNfcanswering null with nothing in any log — rather than with a compile error you could see.
Your tab in the student profile (mobile.student_profile_tabs) — native mode only
A guardian opens the Okta app, sees their children, taps one — and the screen becomes the student profile. This declaration gives you your own tab inside that screen: you own its whole body, your information and your actions and your layout, not a card in a template you fill in.
"mobile": {
"supported": true,
"mode": "native",
"minContract": 17,
"student_profile_tabs": [
{
"key": "attendance",
"title": { "ar": "الحضور", "en": "Attendance" },
"entry": "okta_app/native/student_tab/lib/main.dart",
"portals": ["guardian"],
"order": 10
}
]
}
This is not the studentProfile block. That one adds panels, stats and
actions to the student-profile page in okta-web, read by staff, and is
discovered from Livewire classes in your repo. This is a native mini-app
mounted on a guardian's phone. Two audiences, two hosts, two runtimes — so
they are not one array. See Extending the student profile
for that one.
And it is not an account type. mobile.audiences[] answers who opens
your app; this answers where a surface mounts. Merging them would make
"guardian" mean two different screens depending on a sibling field.
Fields
| Field | Required | Note |
|---|---|---|
key |
^[a-z][a-z0-9_-]{0,63}$. An identity, not a label: the platform remembers the guardian's last-opened tab by it, so changing it forgets their choice. |
|
title |
{ar, en}; Arabic is required. A bare string is read as the Arabic title. Up to 60 chars. |
|
entry |
okta_app/native/<package>/lib/<file>.dart. May be the same file as your portal card, or its own package. |
|
portals |
["guardian"] by default. "student" is the same screen a student opens on themselves. |
|
order |
Sort among your own tabs. Your position against other apps' tabs is the platform's to decide, not yours. | |
icon |
Up to 64 chars. |
Four conditions, each of which drops the whole tab
nativeonly. The tab is handed the student's identifier. Underwebvieworexternalthat identifier would have to be posted to a partner-hosted page — moving a child's id onto your server every time the screen opens, outside theOkta.*calls okta-web authorizes one by one. A declaration under any other mode is dropped.minContract≥ 17. The mount point did not exist before that contract. An older device does not error and does not complain — it renders the profile with your tab absent, and nothing in any log says why.- The
education.students.readscope. The tab receives only the student's hashid; without this scope it cannot turn that into a student it knows, so it mounts and shows nothing. - Three tabs maximum. The strip is shared: every app the tenant installed puts its tab in the same row, in front of a parent who came to see their child. An app taking eight seats is not extending the profile, it is annexing it — and the guardian pays in horizontal scrolling.
What you receive at mount — and how to read it
Your tab is mounted for one student, and you receive the student's hashid, never a numeric id. Not cosmetic: a sequential integer would let an app walk the school roster by incrementing; a hashid would not.
Two calls, new in contract 17:
// The student the tab was opened on. A function, not a property.
final studentHashid = Okta.studentHashid();
// Who is reading: 'guardian' or 'student'. One screen, two readers.
final portal = Okta.portal();
Both answer '', never null, when the surface is not a student-profile
tab — the ordinary case on every other surface of your app. '' is not an
error; it means "this is not a student-scoped mount". Do not fall back to
"the current user" there: in a guardian's context there is no student in the
session to fall back to, and an app that guesses one shows a parent another
family's child.
Why
''and notnull? Because'$value'on a null produces the four charactersnull— a perfectly well-formed string that walks straight into an API path with nothing to stop it. An empty string is checkable;'null'is not.
Authorization is not yours to do and not yours to bypass. okta-web re-runs it on every call (a linked guardian, the student themselves, or a delegate with an effective delegation), so a tab reaching for another student gets a 403 rather than data. Do not build your own check, and do not stash the hashid to read later in some other context.
A complete, working example
okta_app/native/student_tab/lib/main.dart — an attendance tab listing the
student's absences:
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
Widget main() => const AbsencesTab();
class AbsencesTab extends StatefulWidget {
const AbsencesTab({Key? key}) : super(key: key);
@override
State<AbsencesTab> createState() => _AbsencesTabState();
}
class _AbsencesTabState extends State<AbsencesTab> {
bool loading = true;
String error = '';
List<dynamic> rows = <dynamic>[];
@override
void initState() {
super.initState();
load();
}
Future<void> load() async {
final sid = Okta.studentHashid();
// "Not a student-scoped mount" is a legitimate state, not a failure —
// say something a reader understands instead of calling the API with an
// empty identifier.
if (sid.isEmpty) {
setState(() {
loading = false;
error = 'Open this tab from one of your children’s profiles.';
});
return;
}
// Your own namespace. okta-web has already checked this reader may see
// this student before the call reaches your code.
final res = await Okta.get('/api/my-app/absences?student=' + sid);
setState(() {
loading = false;
if (res.ok) {
rows = res.body['data'];
} else {
error = 'Could not load the record.';
}
});
}
@override
Widget build(BuildContext context) {
if (loading) {
return const Center(child: CircularProgressIndicator());
}
if (error != '') {
return Center(child: Text(error));
}
if (rows.isEmpty) {
// "No absences" is good news to a parent — say it, don't render an
// empty table.
return const Center(child: Text('No absences this term. 🎉'));
}
// "Your child" to a guardian, "you" to the student: one screen, two
// readers.
final who = Okta.portal() == 'guardian' ? 'Your child' : 'You';
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Text('$who missed ${rows.length} day(s) this term'),
),
for (final row in rows)
ListTile(
title: Text('${row['date']}'),
subtitle: Text('${row['reason']}'),
),
],
);
}
}
Three things worth noting in this example:
main()returns a Widget, like any other mini-app — a tab is not a new kind of program, it is the same surface with a different mount point. Everything you know aboutnativeapplies: the supported dart_eval subset, the limits, and the traps below.- The empty state is not an error. Someone who reached the tab from outside a student profile deserves a sentence explaining that, not a spinner that never stops and not an API call with a blank identifier.
- Never show a parent an empty table. "No absences this term" is the information; it is what they came to find out.
Your scopes, and the platform's data
education.students.read is required because the tab receives an identifier
and nothing else. If you need more than turning that into a student — the
grade, the section, the timetable — request the matching scopes as described in
Scopes. Your OWN data comes from your own namespace
(/api/<slug>/…) with no extra scope, and it is usually most of what the tab
shows.
Traps
- A dropped tab leaves no trace. Any of the four conditions above drops the
row at build time: the manifest publishes, the app installs, and the tab is
simply not there on the parent's phone. That is why the version editor and
validate_manifestrefuse the row with an error, not a warning — a refusal where you read it beats an absence you never see. - Empty is worse than absent. A tab with an appealing title that shows nothing until data is set up is better left undeclared. Declare it when it has something to say.
- The title has exactly one audience: the guardian. Do not put your app's name or an internal term in it — your app's name is already shown next to the tab.
Traps the devices proved — read these before you ship
Every item here took down a real app on a real phone, and none of them is caught
by flutter analyze or tool/validate.dart. Compilation succeeds; the
interpreter then dies in the user's hand.
1. Okta.* is a BRIDGE CALL, not a field read — cache it once.
Every call round-trips to the host and comes back as JSON to be decoded again.
Every time. One attendance app wrote:
bool get _ar => Okta.locale().startsWith('ar'); // a getter — 188 calls per build
String _t(String ar, String en) => _ar ? ar : en;
Read the identity once in initState and keep it in fields:
late final bool _ar;
@override
void initState() {
super.initState();
_ar = OktaText.isRtl(Okta.locale());
}
It cannot change without the host rebuilding the mini-app anyway. The rule is
wider than identity: treat every Okta.* as network, not as a property.
1b. A value that ARRIVED from outside is not an interpreter value — and the
message names the argument, not the receiver. Calling a bridged method
(startsWith, toLowerCase, trim, contains, substring, even
toString) on a value that came from outside the interpreter dies with:
type 'String' is not a subtype of type '$Value?' in type cast
#1 $String._startsWith
args[0] is perfectly fine — the receiver is the wrong shape, so do not
chase the argument the trace names. Okta.* was fixed from the inside and no
longer hands out values in that shape, but a JSON reader you write does:
String _text(dynamic map, String key) {
return map[key] as String; // the cast passes; the shape stays wrong
}
String _text(dynamic map, String key) {
final dynamic value = map[key];
return '$value'; // interpolation makes the interpreter allocate it
}
Fix it at the mint, not at the call sites, and do the same in the int /
double branches (value.toString() carries the identical defect and merely
fires less often). For bools use == true rather than as bool.
2. Never nest ternaries — in an argument or an assignment alike.
final way = a ? (x ? '…' : '…') : (b ? '…' : '…'); // arrives null
The compiler allocates the outer conditional's result slot and the inner one writes elsewhere:
*L11: null, L12: null
8484: BoxString (L12) <<< EXCEPTION
Branch and return instead. A single ternary is fine. And the type in the
message (String, int) is just whatever the expression produced — do not read
it as narrowing the rule.
3. An uncalled private method fails your own gate.
unused_element is a warning, and flutter analyze is part of
partner-module-policy. Delete a button, delete its handler with it.
4. A device call can still refuse — check what it returns.
The platform's capability gate is gone, so nothing is withheld for lack of a
declaration. What remains is the real world: hardware that is absent, an OS
permission dialog the person declined, a reader that never paired. Every call
answers in its own documented refusal shape — scanNfc → null,
storePut / openDocument / toolKeyStart → false, location → a negative
accuracy — and none of them throws. Ignore the return value and your screen
shows a button that silently does nothing.
5. A stale ref makes your gate validate something the device does not have.
The okta_miniapp pin in okta_app/native/<entry>/pubspec.yaml does not
decide what the device compiles against — the host injects
okta_host/okta_kit/okta_motion from its own binary. It decides what
flutter test tool/validate.dart checks. So a pin behind the host means a gate
validating a source that does not exist, and the failure surfaces on a user's
phone:
CompileError: Cannot find static method OktaTabs.bottomBar
A symbol the host knows and your pin does not, or the reverse. The platform manages this value; if you hit an on-device compile error while your gate is green, the ref-versus-host match is the first thing to check.
6. Silent failure is worse than failure. A local capture is confirmed before the network is consulted — which is correct, it is what makes a scan survive a dropped connection — but it means an upload that never landed looks identical on screen to one that did. Say so: a sound, a message, a log row. Someone standing at a gate is watching the student, not a badge in the header.
7. 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: 22only if you receive. An app that publishes but never listens stays on its current floor and keeps working on every device in the field — and raising the floor without needing to does not make your app newer, it makes it refuse devices it used to run on.
Publishing — POST /api/apps/realtime/publish
Behind the realtime.messages.write scope. Declare it in scopes like any
other.
await Okta.post('/api/apps/realtime/publish', {
'event': 'class.called',
'data': {'section': 'A-3'},
});
The body takes two keys and no others: event and data. A 202 answers:
{
"published": true,
"channel": "private-app.01M1TTHDSWKEXCCATPVA7MP8N5.roll-call",
"sent_at": "2026-09-06T07:39:46+00:00"
}
And this is what reaches your surfaces on the channel:
{
"event": "class.called",
"data": {"section": "A-3"},
"sent_at": "2026-09-06T07:39:46+00:00",
"from": "server"
}
Your event name never becomes the broadcast event name. The broadcast event
is the fixed string app.message for every message from every app, and
class.called travels inside the payload under event. Broadcasting under
your name would let an app broadcast device.command.queued — or any name a
platform surface binds on — and a client would act on a message a partner
wrote. One frame: the client binds once, and your vocabulary is data.
Refusals — and what to do about each
| Status | error |
What happened, and what you do |
|---|---|---|
403 |
scope_not_granted |
realtime.messages.write is not granted to this installation. Declare it and republish; the tenant grants it on update. |
422 |
invalid_event |
The event name does not match ^[a-z][a-z0-9_.:-]{0,63}$ — lowercase, starting with a letter, at most 64 characters. |
422 |
payload_too_large |
The data block exceeded 8192 bytes of JSON. Send a reference and let the surface fetch the rest over the API. |
429 |
rate_limited |
You exceeded 60 messages a minute for this installation. The body carries retry_after in seconds, and the response carries a Retry-After header too. |
The refusal text names the fix, not just the fault:
{"error":"payload_too_large","message":"The `data` object is 9008 bytes of JSON; the limit is 8192. Send a reference and let the surface fetch the rest over the API."}
{"error":"rate_limited","message":"This installation has published 60 messages in the last minute. Retry in 48s, or batch what you are sending into fewer messages.","retry_after":48}
The two limits, and why they sit where they do
8 KB on data, so the channel does not become a second data path: anything
that needs data reads it from the API with its usual scope, which keeps scope
enforcement on one door. Send "section A-3 was called", not the section
roster.
60 messages a minute per installation — that is, per (school, app) pair. Not per app and not per school, because the pair is exactly the blast radius of a loop: a per-app limit would let a runaway loop in one school's copy silence your app in every other school, and a per-school limit would let one chatty app spend every other app's budget.
What is deliberately absent from the body
No tenant_id, no module. Tenant and module are read from the
server-side installation context alone, and there is no place in the body to
write them. Such a field would be an assertion, and honouring it would let
an app installed in one school address another school's screens — the one thing
the whole installation model exists to prevent. Send them if you like; they
change nothing.
And no Idempotency-Key. That header replays a stored response for 24
hours so a write does not happen twice, and nothing is written here. An
instant message served from a cache is a message that did not arrive while
the caller is told it did — worse than a visible error.
A publish whose broadcast fails still returns 202 and logs a warning. The caller already did the real work — recorded the attendance, called the class — and the message only announces it; failing the request would undo something that happened for the sake of an announcement that did not.
Receiving — Okta.onMessage (contract 22)
Okta.onMessage((String event, Map data) {
if (event == 'class.called') {
setState(() { _section = '${data['section']}'; });
}
});
One handler; a second call replaces the first. There is no handler list and
no unsubscribe, and both absences are deliberate:
- A handler list asks a question the sandbox cannot answer: which of them owns this message? One app, one channel, one handler.
- Unsubscribing has nothing to do: the handler dies with the mounted mini-app, because the host opens the socket on mount and closes it on teardown. An unsubscribe symbol would add exactly one thing — a way for you to get the lifetime wrong and go silent while still on screen.
And there is no send symbol. Okta.post has sent since contract 1, and a
second symbol for the same act is a second write path that drifts.
The socket belongs to the mounted app alone. Each target gets its own delegate, and each delegate its own socket and channel — so there is no shared socket for anything to leak through, and no app hears another's messages.
The channel name comes from the launch, not from the bundle. The bundle is
reused for as long as payload_version holds, so a channel carried on it would
be the channel of whichever tenant opened it before the school was switched — a
socket that authenticates cleanly and delivers another school's messages. The
launch is re-asked on every open, and the host takes the channel from there.
You never touch any of this: you call onMessage and nothing else.
Listening needs no scope — the installation is the permission. A scope is
what a school grants an app so it can reach the school's data; this channel
carries only what your app itself sent to its own surfaces, in the school that
installed it. What must hold is that the installation is live and active,
and that is re-checked on every subscribe: uninstalling or suspending closes the
channel. That is why there is no realtime.messages.read and never will be
— a grant that is requested and buys nothing is a grant partners think they
need.
The rule that saves your app: never wait for a message in order to draw
A dead socket changes nothing on screen. The realtime block on the launch
payload is optional, and its absence is a normal state, not a fault: no
broadcaster deployed, a server older than the feature, a platform where the
socket does not open. Every failure after that is swallowed and logged:
refused auth, dead host, malformed frame, a handler that throws.
And from inside the sandbox, a host that never calls your handler and a silent channel are the same thing — you have no way to tell them apart. So there is one rule:
Read state with
Okta.get, then let messages update it. An app that draws an empty screen and waits for its first message shows that emptiness forever on any device outside coverage — which is exactly what a television in a school with a flaky network looks like.
A socket that never connects costs you freshness and nothing else. And the host retries for you: backoff starting at 2 seconds, doubling to a ceiling of 5 minutes, with ±20% jitter, and the counter resets on a successful subscribe — not on a successful connect.
Do not throw inside the handler. It is interpreted code invoked from a
socket callback — outside any build, so there is no error boundary beneath
it. The host wraps it twice as a backstop, but keep the body defensive: read
from data carefully, and assume no key.
And as the publisher: emit every field,
nullwhen empty. A key merely missing from the decoded map is a rawnullin dart_eval —isthrows on it, and evenOktaJson.strthrows. Keep your payload a fixed shape and fill the gaps with an explicitnull. You are both ends here, so the contract is between you and yourself.
A worked example: "class roll call"
One app, two surfaces: a teacher calls a section from their phone, and the classroom screen updates with no polling.
The phone surface — publishes after doing the real work:
Future<void> callSection(String section) async {
// 1) The real work first: it is recorded on your own server.
final res = await Okta.post('/api/roll-call/calls', {'section': section});
if (res.status < 200 || res.status >= 300) {
Okta.toast('Could not record the call');
return;
}
// 2) Then the announcement. Its failure does not undo (1).
await Okta.post('/api/apps/realtime/publish', {
'event': 'class.called',
'data': {'section': section, 'called_at': '${DateTime.now()}'},
});
Okta.playSound('success');
}
The screen surface — draws from state, then messages update it:
class Board extends StatefulWidget {
const Board({super.key});
@override
State<Board> createState() => _BoardState();
}
class _BoardState extends State<Board> {
String _section = '';
@override
void initState() {
super.initState();
// First: draw what is true now. This is the screen on a device
// whose socket never connected — and it is a correct screen.
_loadCurrent();
// Then: let the message update it. Nothing here gates the draw.
Okta.onMessage((String event, Map data) {
if (event != 'class.called') return;
final dynamic v = data['section'];
setState(() { _section = '$v'; });
});
}
Future<void> _loadCurrent() async {
final res = await Okta.get('/api/roll-call/calls/current');
if (res.status != 200) return;
final dynamic v = res.json()['section'];
setState(() { _section = '$v'; });
}
@override
Widget build(BuildContext context) {
return Center(
child: Text(
_section.isEmpty ? 'No call right now' : 'Called: $_section',
),
);
}
}
And in your manifest — on the screen package alone, if that is the receiver:
"mobile": {
"minContract": 22,
"screen": {
"enabled": true,
"title": "Class roll call",
"places": [
{ "scope": "section", "entry": "okta_app/native/screen/lib/main.dart" }
]
}
}
Note what is not in the example: no polling loop, no
Timer, noFuture.delayedre-asking the question. And if the socket drops, the screen keeps whatever_loadCurrent()last read — stale, not blank.
The realtime block on the launch payload
The launch payload carries a realtime block (on the screen launch too) with
the driver, key, host, port, channel name and auth endpoint. You do not
use it — the host consumes it and opens the socket; it is mentioned here only
because it explains why the channel comes from the launch and not from the
bundle.
When the broadcaster is not configured the block arrives as null, not as a
missing key: absence would force a client to conflate "server older than the
feature" with "feature switched off" — two facts with two behaviours.
Audible announcements (audio & speech)
A student-call app announces a child's name when their guardian reaches the
gate, and its settings offer the school four sounds: a gentle bell, the name
spoken by a synthetic voice, a recording the school made, and a
recording the guardian made. In a browser all four worked. On the two
surfaces that matter — the display on the wall and the employee's phone — only
the bell did, because the host's entire audio surface was
Okta.playSound(name): a closed vocabulary of three system tones. Three of the
four modes had no path at all.
Contract 23 opens that path with four calls.
The four, and two capabilities rather than one
| Call | What it does | What false means |
|---|---|---|
Future<bool> Okta.playAudio(String url) |
Play an audio file from a URL your own server serves | Playback did not start: an invalid URL, a file that cannot be reached, or a speaker that will not respond |
void Okta.stopAudio() |
Silence whatever is sounding — the file and the voice alike | — (returns nothing, and cannot fail) |
Future<bool> Okta.speak(String text) |
Speak a string in the device's voice | Speech did not start: no engine, or no voice for the language asked for |
Future<bool> Okta.canSpeak() |
Can this device speak? | It cannot — and that is a sound answer, not a fault |
Two capabilities, not one, because the modes are different things. The two
recordings — the school's and the guardian's — need a file played
(playAudio); only the synthetic voice needs speech (speak). So an app
that offers recordings alone does not need a TTS engine to exist on the device
at all, must not ask about one, and must not disable itself over its absence.
Ask canSpeak() before you offer "announce by name" — this is the point
Many Android TV boxes ship with no Arabic voice whatsoever. Without the probe your app picks the spoken mode, the wall goes silent, and nothing anywhere says why: no error on screen, no line in a log, and no visible difference between "this device has no voice" and "the call never arrived".
falseis an ordinary state on a healthy device. It is not a fault, and not something to retry — asking again in two seconds answersfalseagain until somebody installs a voice on that device. The correct response is to step down: the recording, then the bell.
Ask it in two places: when you draw your app's settings, so you never offer a school an option that cannot work on its screen; and at the moment of the announcement, so you never assume that what was true at configuration time is still true.
And the answer is not cached — not by the host, and not by you. A school that installs an Arabic voice on Tuesday uses it on Tuesday; a wall display is not restarted for months, so an answer cached at startup means an installed voice goes unused until the next holiday.
On Windows the host resolves the voice by listing the engine's languages
(the per-language probe is Android-only) and returns the engine's own spelling
of the tag. You do none of this: the part that concerns you is that canSpeak
answers truthfully on both platforms.
Nothing throws — every answer is a bool
A dead speaker, a 404 on the recording, a missing engine: all false. None
of the four raises an exception up to you.
That is deliberate, because the audience is a wall: on a display with nobody
standing at it, an exception is a red screen nobody clears until the school day
ends. So check the returned value, and do not wrap the call in a try.
playAudio takes a URL your own server serves
The file is fetched by the platform's audio player, never by the
authenticated client: no bearer and no tenant header rides to a partner file
host. The URL therefore reaches only what the device could have fetched
anonymously, and the call hands back a bool, not bytes.
In practice:
- Absolute
http(s)only;file:///and its friends are refused. - The file must be served without Okta authentication. A short-lived signed URL
your own server issues is a sound answer; an endpoint expecting an Okta token
is not — it will answer 401 and you will get
false. - If the recording lives behind your app's own domain, expose it on a public
path your server serves. Do not pass a relative
/api/…path: this call is notOkta.get.
One voice in the hall
A second playAudio replaces whatever is sounding, and stopAudio()
silences both the player and the voice. There are never two channels open
at once.
There is one loudspeaker on a wall, and two names overlapping in a lobby is worse than one name arriving late. So if two calls land back to back, the later one is what is heard — and that is the behaviour you want by default.
true means "started", not "finished"
There is no completion signal on this surface. The Future resolves when the
sound starts, so you cannot chain two calls today — "ring the bell, then
speak the name" is not expressible, because await on playAudio does not
wait for the clip to end.
A known limit, stated now rather than discovered as a queue of announcements trampling each other on a school wall. If you need a sequence, space it with a delay you choose, and know that it is an estimate.
And why false can arrive late
playAudio may answer false late when the server is unreachable: the
player waits out its own preparation timeout.
This is a choice, not an oversight. A fast "no" followed by a delayed noise would make the wall play the fallback bell and the recording both — so answering quickly here would buy exactly what the section above prevents.
playSound is unchanged and is not replaced
The bell is a confirmation drawn from a closed, host-owned vocabulary
(success · error · warning, 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: 0or4xx/5xxand anerror← a failure. Show it.
And uploadRecording never returns null for a retired handle or a host
with no microphone: those are error results, because nobody cancelled anything —
and an app that reads one as a cancellation drops a voice note believing it sent
it.
Nothing throws
No microphone, a denied permission, a closed picker, a retired handle, a network
that died mid-upload — all false, null, or an error result. Do not write
try around any of the seven; whoever reads "this can fail" writes one, and
the exception does not happen here.
Where it does not work, said honestly
- Web (the browser build and the simulator):
canRecord()answersfalse. The recording package on the web hands back a blob rather than a file, so a take could not travel the same upload leg. - The simulator: refuses all seven, and its file picker answers a
cancellation, exactly as
uploadFilehas since the beginning. A simulator that always says yes never once exercises the fallback path you are being asked to write, and the first place you would find it missing is a screen in front of a real person. - Display screens (okta-screen): refuse all seven — a television on a wall has no microphone and nobody standing at it to pick a file.
So try the recorded path on a real device, and write the fallback that works
when canRecord() answers false.
No manifest key, and no scope
Exactly as contract 23: these are host symbols. There is nothing to add to
manifest.json and no scope to request from the school — the microphone is the
microphone of the device your app was opened on, and the file goes to an API
path your usual scopes already govern. The only thing that moves is
minContract.
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:
BackdropFilterandImageFilter.blurare not in the bridge's vocabulary at all — no blur.BoxDecorationcarriescolorandboxShadowonly;gradientis commented out of the bridge declaration, and an undeclared named argument is silently dropped. So the raked tint compiles cleanly and never reaches Flutter — you get a flat rectangle with not one error, andvalidate.dartdoes not catch it because nothing failed.BoxDecoration(border: …)does not compile at all — no specular rim.
And that indirection is the feature, not the cost. The drawing lives in the
host's binary, not in your bytecode. So when Okta restyles the material — its
colour, its blur, its edge — the change reaches every published mini-app on
the next host release: no rebuild, no new version, not one line you edit. A
partner who hand-rolled the look from ClipRRect and ColoredBox is frozen at
the day they shipped.
package:okta_glassneedsmin_contract: 16or higher. Declare it in the manifest, or compilation fails on a device carrying an older host.And a contract's features are only real once a host build carrying them reaches the user's device. Raising
min_contractahead of that does not make your app newer — it makes it refuse to run on every device in the field until the store update lands. Check the published host contract in the partner dashboard before you depend on it.
The palette — the single source of colour
Never write a colour literal. Theme.of(context).colorScheme does not cross
the bridge, so the palette is your only route to the host's identity:
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const HomeScreen();
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
// Build it once at the root and pass it down. Do not rebuild it per widget.
final palette = OktaPalette.of(Okta.isDark());
return Scaffold(
backgroundColor: palette.surface,
appBar: OktaAppBar.build("Today's schedule", palette),
body: /* … */,
);
}
}
| Role | Field | Use it for |
|---|---|---|
| Brand | palette.brand |
Primary buttons, the selected tab, the accent icon |
| Brand soft | palette.brandSoft |
Touch ripple, hover wash |
| On brand | palette.onBrand |
Text and icons on top of brand |
| Ground | palette.surface |
The screen background — Scaffold.backgroundColor |
| Raised surface | palette.surfaceRaised |
Cards and blocks sitting on the ground |
| Strong text | palette.textStrong |
Headings and body |
| Muted text | palette.textMuted |
Descriptions and secondary data |
| Border | palette.border |
Hairlines |
brand is not the same colour in both modes: #6D428F in light,
#A57BBA in dark. Saturated brand purple over a near-black ground glows and
hurts legibility. Read it from the palette; never hard-code it.
These values mirror
OktaTokensin the mobile app, and a test there —okta_kit_palette_parity_test.dart— fails the build if the two copies drift. What you read here is what actually runs on the device.
Recipes that give you Okta's shape despite the bridge
The route to the host's look is not copying its widgets — it is matching its numbers with the tools that work.
A rounded card. Not BoxDecoration(borderRadius:) — it is silently dropped
and paints square corners. Corners come from ClipRRect:
Widget card(OktaPalette palette, Widget child) {
return ClipRRect(
borderRadius: BorderRadius.circular(16), // radiusXl — the card radius
clipBehavior: Clip.antiAlias,
child: ColoredBox(
color: palette.surfaceRaised,
child: Padding(
padding: const EdgeInsets.all(16), // space4
child: child,
),
),
);
}
A hairline. Not Border.all — it does not compile. A hairline is a box one
pixel tall:
Container(height: 1.0, color: palette.border)
Elevation — and a trap worth knowing. boxShadow is supported, but a
shadow and a rounded corner cannot coexist: rounding works only through
ClipRRect, shadows only through BoxDecoration, and BoxDecoration carries
no borderRadius. So a shadow behind a rounded card is drawn square and
shows at every corner.
Either a flat rounded surface separated by its colour (OktaSurface.card does
this), or OktaGlass.card, which gets blur, edge and shadow at once because the
host draws it. A hand-rolled shadow is only ever right on a square-cornered box.
The primary button — built, not configured. ElevatedButton and
TextButton do not accept style on this engine; the parameter is not
declared in the bridge, so your colour is silently dropped and the button
arrives in the default grey. The button that carries your identity is
assembled:
Widget primaryButton(OktaPalette palette, String label, void Function() onTap) {
return GestureDetector(
onTap: () => onTap(), // a closure, not a function reference
child: ClipRRect(
borderRadius: BorderRadius.circular(999),
clipBehavior: Clip.antiAlias,
child: ColoredBox(
color: palette.brand,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Center(
child: Text(
label,
style: TextStyle(
color: palette.onBrand,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
Use ElevatedButton for a secondary action whose colour does not matter, and
this for the primary one.
Text alignment. Text does not accept textAlign here, and TextAlign is
not a bridged type at all. Alignment comes from Center or Align around the
text, not from inside it.
The numbers that make you look like us. Match them; do not invent others:
| Value | |
|---|---|
| Card radius | 16 — capsule 999 |
| Card padding | 16, compact 12 |
| Spacing rhythm | multiples of 4: 4 · 8 · 12 · 16 · 24 · 32 |
| Text sizes | 12 faint · 14 secondary · 16 body · 18 section title · 20 screen title |
| Weights | 400 body · 500 label · 600 heading — never 700 |
| Motion | 150 fast · 220 base · 320 slow |
Motion. Use package:okta_motion — its easeOutCubic is the host's own
curve, so your app does not feel faster or slower than the screen it opened
from:
import 'package:okta_motion/okta_motion.dart';
OktaMotion.entrance(index * 40, 220, card(palette, row))
entrance(delayMs, ms, child) is the same stagger the home screen's cards enter
with: 40ms between one row and the next. Cap the delay at ten rows — the tail of
a long table must not arrive two seconds late.
Icons. OktaIcons.* only. Arbitrary Icons.* constants are not bridged,
and worse, the app is built with --no-tree-shake-icons precisely because your
code points are chosen at run time: an icon outside the set can reach the device
as an empty box with no error at all.
Text direction
The host is Arabic-first. AlignmentDirectional is not bridged, so direction
comes from Directionality and OktaText:
final locale = Okta.locale();
Directionality(
textDirection: OktaText.isRtl(locale) ? TextDirection.rtl : TextDirection.ltr,
child: /* … */,
)
Text(OktaText.pick(locale, 'جدول اليوم', "Today's schedule"))
And one rule everyone forgets: identifiers, paths and version numbers stay
LTR whatever the interface language — wrap them in
Directionality(textDirection: TextDirection.ltr) or 04:A2:9F flips on screen.
Dark mode is not optional
Okta.isDark() follows the system appearance on the user's device. An
app that reads the palette once at its root gets both modes for free; an app
that writes Color(0xFF...) once is right in one mode and wrong in the other —
and that is the single most common reason a visual review is rejected.
Pre-publish checklist
- No colour literal anywhere in the code — all of them from
palette.* -
Scaffold.backgroundColor=palette.surface - Cards on
palette.surfaceRaised, corners fromClipRRectnotBoxDecoration - No
Border.all, noBoxDecoration(gradient:)— the first does not compile, the second is dropped in silence - No hand-built glass — use
OktaGlass(andmin_contract: 16) - No shadow behind a rounded corner — it draws square
- No
ElevatedButton(style:), noText(textAlign:)— both are dropped in silence - Components from
OktaButton/OktaSurface/OktaList/OktaStates, not hand-built - Icons from
OktaIconsexclusively - Opened in both light and dark, and the text read in both
- Identifiers and paths wrapped LTR
- All three states handled explicitly: empty, loading, error
A ready prompt for your coding assistant
Paste it verbatim into Claude Code, Cursor or Codex before asking for a screen:
You are building a Dart mini-app that runs inside the Okta mobile app,
compiled on the device by dart_eval. Follow this literally:
Identity:
- Every colour from OktaPalette.of(Okta.isDark()) — no literal
Color(0xFF..).
- Ground palette.surface, cards palette.surfaceRaised, text
palette.textStrong / palette.textMuted, hairlines palette.border.
- Card radius 16, capsule 999, padding 16, spacing in multiples of 4.
- Text sizes 12/14/16/18/20, weights 400/500/600 only.
- Icons from OktaIcons only.
- Components from okta_kit, never hand-built: OktaButton.primary/secondary,
OktaSurface.card/hairline/pill/iconTile, OktaList.row/sectionHeader,
OktaStates.loading/empty/failure, and the numbers from OktaMetrics.
- Motion from package:okta_motion, durations 150/220/320.
Engine limits — every one of these breaks:
- BoxDecoration carries color and boxShadow only. borderRadius and
gradient are silently dropped: round corners with ClipRRect + ColoredBox.
- BoxDecoration(border:) does not compile: a hairline is
Container(height: 1.0).
- No BackdropFilter, no ImageFilter. The material comes from
package:okta_glass: OktaGlass.card / chrome / field / background
(needs min_contract 16).
- A shadow and a rounded corner cannot coexist — use OktaGlass.card to lift.
- Buttons: ElevatedButton and TextButton only, and **neither accepts style**:
build the primary button from GestureDetector + ClipRRect +
ColoredBox(palette.brand).
- Text does not accept textAlign: align with Center or Align around it.
- Layout: Row, Column, Expanded — and always pass flex:. No Wrap, no
AlignmentDirectional.
- No nested loops at all — flatten hierarchical data into one list.
- Callbacks are closures: onPressed: () => f() not onPressed: f.
- Keep JSON dynamic end to end; never give the receiver a Map type.
- No State.mounted, and no Timer (but DateTime, Duration and Future.delayed
exist — 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.dartbefore publishing — it catches silently dropped parameters and the three traps above before a user does.
The MCP server's
component_catalogtool serves the same set live from the platform — if your assistant is connected to it, ask before assuming.
Example: an attendance-by-scan app (native) — from nothing to published
One practical chapter that builds a complete Dart mini-app: a supervisor stands
at the gate, waves student cards past the phone, and every read is recorded as
an attendance — and kept on the device when the network drops. A real example,
because it is what actually gets built, and because it touches everything a
native app needs: state, pages, permissions, scanning, the network, storage,
the three states, and the identity.
Before you write a line: four limits that shape the design
These are not preferences. Each one changes what your app can be.
1. There is no text input. TextField is not in the bridged surface. A
native app is a read-and-act app: it shows, it scans, it taps, it sends.
Any screen whose whole point is that someone types into it — a registration
form, free-text search, a comment — is not a native app; build it in
webview or external mode. Discovering this two weeks into the work is the
most expensive lesson on the platform, which is why it is the first line here.
2. There is no navigation. Navigator.of is declared in the bridge with no
implementation and no static dispatch entry, so the NavigatorState can never
be obtained from inside the sandbox, and there is no route stack there either.
Two screens inside your app means one piece of state holding a page number —
the full pattern is below.
3. No nested loops. dart_eval 0.8.5 throws a compile-time RangeError
when a loop body contains another loop — directly, or through a function it
calls. Flatten hierarchical data into one list, on the server where you can.
4. A shadow and a rounded corner cannot coexist. Rounding works only through
ClipRRect, shadows only through BoxDecoration, and BoxDecoration carries
no borderRadius. When a surface has to lift and be round, use
OktaGlass.card — the host draws that one natively and it gets all three.
0 · Layout and manifest
okta_app/native/attendance/
├── pubspec.yaml ← pins okta_miniapp — do not bump the ref yourself
├── analysis_options.yaml
├── lib/
│ └── main.dart ← the entry point: Widget main()
└── tool/
└── validate.dart ← the same gate CI runs
And the manifest:
"mobile": {
"supported": true,
"mode": "native",
"entry": "okta_app/native/attendance/lib/main.dart",
"minContract": 16
}
minContract: 16 because we use package:okta_glass. Nothing else to declare:
device access needs no manifest entry since the capability gate was removed.
1 · State, and the trap that costs a day
StatefulWidget and setState work. Two traps:
setState(() { _busy = true; }); // block body
setState(() => _busy = true); // boxes twice and throws
An arrow closure returns its body's value, and an assignment is an
expression — so the value is boxed once to write the field and again to return
it, and the second box lands on something already boxed:
type '$bool' is not a subtype of type 'bool'. Convert every site, not only
the ones that crash: whether the double box lands turns on register allocation,
not on anything visible in the source.
The second: there is no State.mounted — it is unbridged, and naming it
fails to compile. Call setState directly.
2 · Internal pages — the pattern, without Navigator
A page is a number in state, the bar decides what it shows, and the back button goes back internally and then hands over to the host when the pages run out:
class _AttendanceState extends State<Attendance> {
int _page = 0; // 0 = scan · 1 = today's log
void _openLog() {
setState(() { _page = 1; });
}
// One back button for every page. It goes back internally while there is
// somewhere to go, and hands over to the host on the first page — which is
// the only way out of a mini-app.
void _back() {
if (_page != 0) {
setState(() { _page = 0; });
return;
}
Okta.close();
// Doing nothing is a valid host response to close (there may be nothing to
// pop), so never rely on it to stop execution — return straight after.
return;
}
AppBar _bar(OktaPalette palette) {
if (_page == 0) {
return OktaAppBar.build('Attendance', palette);
}
return OktaAppBar.withBack(
"Today's log", palette, Okta.appIcon(22.0), () => _back());
}
}
Do not put the icon in
leadingon the first page. Flutter inserts a back button only whenleading == null, soOktaAppBar.buildWithIconsilently removes the only way out.withBacktakes the slot back for the button and moves the icon intoactions.
3 · Scan — and read what comes back
There is no permission call to make first: the platform's capability gate has been removed, so a scan is just a scan. What has NOT changed is that it can come back empty, and that is the part worth writing code for.
Future<void> _scan() async {
final key = await Okta.scanNfc();
if (key == null) {
// null covers every ending that is not a card: the user cancelled, the
// phone has no NFC, the OS permission dialog was declined. None of them is
// an exception, and none of them should be announced as an error.
return;
}
await _submit(key);
}
4 · Sending — and three endings, not two
OktaApiResponse carries status, body, error and the computed ok. And
the difference everyone forgets: status == 0 means the call never reached a
server at all. Showing "the server returned an error" then is a lie to the
supervisor — the server said nothing.
Future<void> _submit(String key) async {
final res = await Okta.post(
'/api/attendance/reads',
<String, dynamic>{'key': key},
);
if (res.ok) {
Okta.playSound('success');
// body is dynamic end to end — never give the receiver a Map type.
final name = OktaJson.strOr(res.body, 'student_name', key);
setState(() { _log.add('$name · present'); });
return;
}
if (res.status == 0) {
await _queue(key);
return;
}
Okta.playSound('error');
setState(() { _log.add('refused · ${res.status}'); });
}
5 · The local queue — and the silent failure
Future<void> _queue(String key) async {
await Okta.storePut('queued:$key', key);
Okta.playSound('warning');
// Say it out loud. The local capture is confirmed before the network is
// consulted — which is right, it is how a scan survives an outage — but it
// means an upload that never arrived looks **exactly** like one that did.
// Someone standing at a gate is looking at the student, not at a badge in a
// header, so the difference is told with a sound, a message and a log line.
Okta.toast('Saved on the device — no network. It will send when it returns.');
setState(() { _log.add('$key · waiting for the network'); });
}
Okta.storeKeys() and Okta.storeDelete(key) drain the queue later.
Storage is not secure storage. Treat it as readable by anyone holding the device: it is for a capture queue, not for a token.
6 · The list and the three states
Widget _logPage(OktaPalette palette) {
if (_loading) {
return OktaStates.loading(palette);
}
if (_log.isEmpty) {
return OktaStates.empty(
OktaIcons.list(), 'No reads yet', 'Wave a card to start.', palette);
}
// One loop, and a row builder with no loop inside it. A loop within a loop —
// directly or through a function it calls — fails compilation with RangeError.
final rows = <Widget>[];
for (final entry in _log) {
rows.add(OktaGlass.card(
OktaList.row(OktaIcons.check(), entry, 'Today', palette),
));
}
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: rows,
);
}
7 · Identity — without one colour literal
final palette = OktaPalette.of(Okta.isDark());
Scaffold(
backgroundColor: const Color(0x00000000),
appBar: _bar(palette),
body: OktaGlass.background( // one field for the whole screen
_page == 0 ? _scanPage(palette) : _logPage(palette),
),
)
See "Okta's identity inside a mini-app" above for the detail. The rule here is
one line: every colour from palette, every number from OktaMetrics, every
component from okta_kit — so when Okta restyles the material your app changes
without you publishing anything.
8 · Validate and publish
flutter test tool/validate.dart # the same gate CI runs
dart run tool/audit_bridge_usage.dart lib/main.dart # catches the silently dropped
flutter analyzewill complain thatOktaandOktaPaletteare undefined — that is expected. The libraries are injected by the engine at compile time and are not real pub packages.
Then create a new version. The on-device bytecode cache is keyed by
(slug, entry, payloadVersion, runtimeSignature) and never by the content of
the bundle: reinstalling without moving any of those keeps the old copy, with no
message and no warning — you open the app, see the old behaviour, and conclude
your fix did not work, when it never reached the device at all. See "The
mini-app is cached" above for what each part means.
The whole file
import 'package:flutter/material.dart';
import 'package:okta_glass/okta_glass.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';
Widget main() => const Attendance();
class Attendance extends StatefulWidget {
const Attendance({super.key});
@override
State<Attendance> createState() => _AttendanceState();
}
class _AttendanceState extends State<Attendance> {
int _page = 0;
bool _busy = false;
final List<String> _log = <String>[];
void _openLog() {
setState(() { _page = 1; });
}
void _back() {
if (_page != 0) {
setState(() { _page = 0; });
return;
}
Okta.close();
return;
}
Future<void> _scan() async {
if (_busy) {
return;
}
setState(() { _busy = true; });
final key = await Okta.scanNfc();
if (key == null) {
setState(() { _busy = false; });
return;
}
final res = await Okta.post(
'/api/attendance/reads',
<String, dynamic>{'key': key},
);
if (res.ok) {
Okta.playSound('success');
final name = OktaJson.strOr(res.body, 'student_name', key);
setState(() {
_log.add('$name · present');
_busy = false;
});
return;
}
if (res.status == 0) {
await Okta.storePut('queued:$key', key);
Okta.playSound('warning');
Okta.toast('Saved on the device — no network. It will send when it returns.');
setState(() {
_log.add('$key · waiting for the network');
_busy = false;
});
return;
}
Okta.playSound('error');
setState(() {
_log.add('refused · ${res.status}');
_busy = false;
});
}
AppBar _bar(OktaPalette palette) {
if (_page == 0) {
return OktaAppBar.build('Attendance', palette);
}
return OktaAppBar.withBack(
"Today's log", palette, Okta.appIcon(22.0), () => _back());
}
Widget _scanPage(OktaPalette palette) {
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: <Widget>[
OktaGlass.card(
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'Hold the student card against the phone',
style: TextStyle(
color: palette.textStrong,
fontSize: OktaMetrics.textBody,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: OktaMetrics.space4),
_busy
? OktaButton.disabled('Scanning…', palette)
: OktaButton.primary('Start scanning', () => _scan(), palette),
],
),
),
const SizedBox(height: OktaMetrics.space3),
OktaSurface.cardTappable(
palette,
() => _openLog(),
OktaList.rowWithTrailing(
OktaIcons.list(),
"Today's log",
'Everything recorded since the day began',
OktaSurface.pill('${_log.length}', palette.brandSoft, palette.brand),
palette,
),
),
],
);
}
Widget _logPage(OktaPalette palette) {
if (_log.isEmpty) {
return OktaStates.empty(
OktaIcons.list(), 'No reads yet', 'Wave a card to start.', palette);
}
final rows = <Widget>[];
for (final entry in _log) {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: OktaMetrics.space2),
child: OktaGlass.card(
OktaList.row(OktaIcons.check(), entry, 'Today', palette),
),
));
}
return ListView(
padding: const EdgeInsets.all(OktaMetrics.space4),
children: rows,
);
}
@override
Widget build(BuildContext context) {
final palette = OktaPalette.of(Okta.isDark());
return Directionality(
textDirection: OktaText.isRtl(Okta.locale())
? TextDirection.rtl
: TextDirection.ltr,
child: Scaffold(
backgroundColor: const Color(0x00000000),
appBar: _bar(palette),
body: OktaGlass.background(
_page == 0 ? _scanPage(palette) : _logPage(palette),
),
),
);
}
}
Example: Daycare Operations app (External)
A self-contained worked example showing how to build an External app that targets daycare center tenants (
daycare_center) — the new tenant type added to the Okta platform for leaf operating entities.
Why External and not Embedded?
Daycare centers need operationally-specific features: attendance check-in/check-out, daily reports pushed to guardians, and authorized pickup management. These are short-lived (daily) records that typically live in the partner's own store and integrate with third-party messaging or biometric hardware. External is the right fit because:
| Reason | Detail |
|---|---|
| Operational data stays with the partner | Attendance logs, daily reports, and pickup lists live in the partner's store, not in okta-web. |
| Independent stack | The partner may use biometric readers, RFID scanners, or existing mobile apps — none of which can be shipped inside okta-web. |
| Real-time guardian notifications | The partner's own messaging channels (WhatsApp, Push, SMS) are already configured on their infrastructure. |
| Full stack freedom | Any language or framework, without Embedded isolation constraints. |
Attendance records, daily reports, and pickup data are not stored in okta-web and are not defined as partner scopes — they are operational data owned entirely by the partner app. Scopes are used only to read the okta-web data needed for correlation (student roster, guardian contacts).
Tenant type daycare_center
The platform has added the daycare_center tenant type to the shared
canonicalTenantTypes catalog, which is pushed from okta-web to
okta-partners over the bridge. This means:
- The type appears automatically in the partner portal — no action needed on your side.
- You can declare
daycare_centeras a primary target tenant type when creating your app. daycare_centertenants can install your app and approve the requested scopes, just like any other tenant type.
No additional code is required to handle this type — once your app is published, the platform surfaces it to eligible tenants.
Required scopes
A daycare operations app needs to read student data from okta-web
to correlate it with its own operational records. Pick scopes from the
scope picker in the partner portal — the catalog is a mirror of
okta-web, kept in sync automatically in the partner_available_scopes
table. Do not hard-code scope names in your code; what appears in the
picker is the authoritative source.
| Scope | Why |
|---|---|
education.students.read |
Read the enrolled children for each tenant and match them with local attendance records. |
education.students.write |
Optional — only if the app needs to write back a custom field (e.g. an RFID card number). Request it only when needed. |
Least-privilege principle: start with
education.students.readonly. If you later need to write, addeducation.students.writein a new version with a clear reason in the manifest.
There is no attendance or daily-report scope because that data lives in your app's own store, not in okta-web.
Manifest
{
"moduleId": "daycare-ops",
"displayName": "Daycare Operations",
"version": "1.0.0",
"category": "operations",
"integrationType": "external",
"description": "Attendance check-in/out, daily guardian reports, and authorized pickup management for daycare centers.",
"scopes": [
{
"key": "education.students.read",
"required": true,
"reason": "Match tenant children with attendance and pickup records"
}
],
"external": {
"webhookUrl": "https://daycare.example/okta/webhook",
"webhookEvents": [
"education.students.created",
"education.students.updated",
"partner.installation.token_rotated"
],
"redirectUrls": ["https://daycare.example/oauth/callback"]
}
}
redirectUrls is useful if you want an OAuth-style flow after
installation — redirect the tenant to /oauth/callback, receive the
installation token, and run an initial roster sync.
Staying in sync via webhooks
After installation you receive subscribed events automatically. Verify the signature on every webhook (see Webhooks):
// Example: handling a student-update event
$event = $request->json('event'); // "education.students.updated"
$data = $request->json('data.student');
match ($event) {
'education.students.created' => $this->syncNewStudent($data),
'education.students.updated' => $this->updateStudentRecord($data),
'partner.installation.token_rotated' => $this->storeNewToken(
$request->json('data.new_token')
),
default => null,
};
return response()->json(['ok' => true]);
Respond 2xx within 10 seconds. For longer processing, return 202 immediately and handle via a background queue.
Recommended events for a daycare operations app:
| Event | When it fires |
|---|---|
education.students.created |
A new child is added to the tenant |
education.students.updated |
A student's data changes (name, contact, ...) |
partner.installation.token_rotated |
The tenant rotated the token — store the new one immediately |
Event names follow the canonical <feature>.<resource>.<action> format;
do not invent custom names.
Initial sync after installation
On first install, page through the full student roster to seed your local store:
# First page
GET /api/apps/education/students?page=1&per_page=100
Authorization: Bearer <installation_token>
# Repeat until last_page is reached
After the initial sync, rely on webhooks for incremental changes. This minimises API calls and keeps your data real-time.
Guardian notifications
Daycare-specific notifications (arrival, departure, daily report) go out through the partner's own channels (WhatsApp / SMS / Push) — they are not part of the Okta notifications system.
If you later want to unify notifications with the Okta platform so tenants can pick channels from their dashboard, you can declare a notifications catalog for your app — see the Notifications catalog section.
Step-by-step summary
- Create an External app in the portal; declare
daycare_centeras a target tenant type. - Request
education.students.readfrom the scope picker (addwriteonly if needed). - Subscribe to
education.students.created,education.students.updated, andpartner.installation.token_rotated. - Add a
redirect_urlif you want an OAuth-style initial-sync flow. - On install: fetch the full roster via pagination, then rely on webhooks for ongoing changes.
- Submit for review — the Okta team checks the manifest and webhook URL, then publishes the app to the marketplace.
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
- OpenAPI: https://partners.getokta.io/docs/openapi.json
- Postman: https://partners.getokta.io/docs/postman_collection.json
- Status: getokta.io/status
- Support: partners@getokta.io