Building your app
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.