Quality & tooling
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']);