Phase 7: Production Readiness — Final (Capstone Review)

This is the last lecture. No new Okta features here — just tying every phase together into a single, coherent picture of what TaskFlow actually is now, and a checklist worth running through before calling any real project done.

What TaskFlow Actually Does Now

Walking back through every phase, in the order a real login actually flows through them:

A user clicks "Log in with Okta" (Phase 2). TaskFlow's own server generates a PKCE code verifier and challenge, redirects the browser to Okta's /authorize endpoint on the custom default authorization server set up in Phase 1.

Okta's hosted page takes over (Phase 4). Depending on the Authentication Policy rules configured, the user might see a password + MFA challenge, a passwordless email flow, a "Sign in with Google" button, or a "Sign up" link if they're new — all without TaskFlow's own code needing to know or care which path was used.

Okta redirects back with an authorization code (Phase 2). TaskFlow's callback route exchanges it — server-to-server, using the Client Secret — for an ID token, access token, and refresh token, verifies the ID token's signature with jose, and stores everything in httpOnly cookies.

Middleware protects routes (Phase 2/3), checking token validity on every request to /dashboard and /admin, silently refreshing expired tokens using the refresh token rather than forcing a re-login.

Claims decide what the user can see (Phase 5). The userRole and groups claims — added to both the ID token and access token as separate claim entries — let /admin distinguish a TaskFlow-Admins member from anyone else, entirely server-side, before any protected content renders.

The backend manages users independently of any login (Phase 6), using a scoped OAuth 2.0 Service App — its own Client ID, private key, custom role, and resource set — to create and update Okta users directly from a Route Handler, gated behind the same admin claims check.

Okta calls back into TaskFlow's own code (Phase 6) via Inline Hooks (shaping a new user's profile before the account is created) and Event Hooks (reacting to a user being deactivated) — both authenticated with a shared secret, both reachable during development through an ngrok tunnel.

Everything is monitored, tested, and deployed properly (Phase 7) — secrets kept out of Git and the browser bundle, the System Log as the first stop for diagnosing anything Okta-side, a three-layer test strategy, and a production deployment using its own separate Okta Application.

A Security Checklist Worth Running on Any Real Project

Before treating an Okta integration as genuinely production-ready, confirm each of these — every one traces back to something specific this course built:

  • Token verification happens on every protected request (Phase 2) — never trust a cookie's presence alone; verify signature, issuer, and audience every time.
  • .env.local is confirmed absent from Git history, not just .gitignored going forward (Phase 7).
  • No secret ever carries a NEXT_PUBLIC_ prefix (Phase 7).
  • The Authorization Server's Access Policy has an active rule scoped to the specific grant types and scopes actually used — not left wide open by accident (Phase 1).
  • Admin-only pages and API routes check claims server-side, never just hide a link in the UI (Phase 5).
  • The Management API Service App uses a scoped custom role and resource set, not a broad standard role or an unscoped legacy API token (Phase 6).
  • Inline/Event Hook endpoints validate the shared secret on every request, since Okta doesn't sign these by default (Phase 6).
  • Recovery authenticator requirements aren't accidentally stricter than sign-in requirements, which could lock out a genuine user (Phase 4).
  • Production uses its own separate Okta Application, never the development one (Phase 7).
  • MFA/adaptive policies exist beyond just password-only sign-in for anything handling real user data (Phase 4).

What's Deliberately Left as "Next Steps," Not Gaps

A few things this course flagged along the way as worth knowing about but genuinely out of scope for a first build — not oversights, but real next steps once TaskFlow needs them:

  • DPoP (Demonstrating Proof of Possession) — turned off in Phase 6 for simplicity; worth enabling once you're comfortable with the additional request-signing it requires.
  • Okta FGA / relationship-based authorization — Phase 5 covered when to reach for it (per-resource sharing, nested permissions) versus the groups-based approach TaskFlow actually uses.
  • SCIM provisioning — relevant once TaskFlow is sold to enterprise customers with their own IT-managed directories, not needed for the individual-signup model built here.
  • Log streaming to a SIEM — the System Log covered in this phase is enough for manual debugging; a real production security team would eventually stream these events to Splunk or similar for automated alerting.

Closing Note

Every lecture in this course was built the same way: research the current state of things first, build it, and — very visibly, across a long stretch of this conversation — get it wrong in small, specific ways that only showed up once real screenshots from a real Okta org were checked against what was written. That back-and-forth wasn't a detour from the course; it's genuinely what working with Okta (or any identity provider) looks like in practice — the console shifts, defaults change, and the only way to be sure something works is to actually click through it and watch what comes back. TaskFlow, as it stands now, has been verified this way at nearly every step.

That's the complete course.

Phase 7: Production Readiness — Part 2 (Testing Authentication Flows & Deploying to Vercel)

Testing Authentication Flows

Testing an OAuth/OIDC flow is genuinely trickier than testing typical app logic, because the "real" flow involves a redirect to Okta's own hosted page, filling in credentials, and possibly an MFA challenge — none of which you control or want your test suite depending on. The right approach is a mix of three layers, each testing a different part of what TaskFlow actually does.

Layer 1: Unit Tests for the Pieces You Wrote Yourself

The PKCE helper and token verification logic from Phase 2 are pure functions — no network calls, no Okta involved — and are the easiest, highest-value things to test directly.

Install a test runner:


    npm install -D vitest

Create src/lib/pkce.test.ts:


    import { describe, it, expect } from "vitest";
    import { generateCodeVerifier, generateCodeChallenge } from "./pkce";

    describe("PKCE helpers", () => {
        it("generates a code verifier of sufficient length", () => {
            const verifier = generateCodeVerifier();
            expect(verifier.length).toBeGreaterThanOrEqual(43);
        });

        it("produces a consistent, deterministic challenge for the same verifier", () => {
            const verifier = "test-verifier-value";
            const challenge1 = generateCodeChallenge(verifier);
            const challenge2 = generateCodeChallenge(verifier);
            expect(challenge1).toBe(challenge2);
        });

        it("produces different challenges for different verifiers", () => {
            const challengeA = generateCodeChallenge("verifier-a");
            const challengeB = generateCodeChallenge("verifier-b");
            expect(challengeA).not.toBe(challengeB);
        });
    });

That length check (>= 43) isn't arbitrary — it's a specific requirement from the PKCE specification itself, and it's exactly the kind of subtle correctness bug (a code_verifier that's technically too short) that passes in a quick manual test but fails against a real provider, as flagged directly in current guidance on this exact topic.

Layer 2: Integration Tests for Your Route Handlers, With Okta Mocked

You don't want tests hitting your real Okta org on every run — it's slow, and it depends on network access and real credentials. Instead, mock the fetch calls to Okta's /token endpoint and test that your callback route handles both success and failure correctly.


    import { describe, it, expect, vi } from "vitest";

    describe("auth callback route", () => {
        it("rejects a request when state does not match", async () => {
            // Simulate a request with a state parameter that doesn't match
            // the one stored in cookies — this should always be rejected,
            // regardless of whether the authorization code itself is valid.
            // ... construct a mock NextRequest with mismatched state ...
            // expect(response.status).toBe(307); // redirect to /login?error=...
        });

        it("rejects tokens that fail signature verification", async () => {
            vi.mock("@/lib/verifyToken", () => ({
                verifyIdToken: vi.fn().mockRejectedValue(new Error("invalid signature")),
            }));
            // ... exercise the callback route and confirm it redirects to an error state
            // rather than setting session cookies with an unverified token.
        });
    });

The specific value of this layer: it catches exactly the kind of bug that's invisible when you're manually clicking through the login flow yourself (since you're always providing a correctly formed request) — a missing state check, or a code path that sets session cookies before verification completes.

Layer 3: One Real End-to-End Smoke Test

This is the layer that actually drives a browser through the real flow, and it's the one genuine current guidance is clear about: don't skip this layer entirely in favor of mocking everything — a misconfigured redirect URI, a broken PKCE implementation, or a missing code_challenge_method=S256 parameter will pass every mocked test and only fail against the real provider.

Install Playwright:


    npm install -D @playwright/test
    npx playwright install

Automating Okta's hosted login page directly is realistic but adds friction if MFA is in the way (typing a real TOTP code in an automated test is painful). The practical pattern: create a dedicated QA test user, and scope an Authentication Policy rule (using the same rule-priority technique from Phase 4) so that user can log in with a single factor — for example, reusing the passwordless email rule, or simplest of all, a rule allowing just Password for that one specific test account. This isn't a security compromise for production (the rule can be scoped narrowly to just that one QA account), and it keeps the E2E test deterministic.


    import { test, expect } from "@playwright/test";

    test("a user can log in and reach the dashboard", async ({ page }) => {
        await page.goto("/login");
        await page.click("text=Log in with Okta");

        // Now on Okta's hosted page
        await page.fill('input[name="identifier"]', process.env.QA_TEST_USER_EMAIL!);
        await page.click("text=Next");
        await page.fill('input[name="credentials.passcode"]', process.env.QA_TEST_USER_PASSWORD!);
        await page.click("text=Verify");

        // Back on TaskFlow
        await expect(page).toHaveURL(/\/dashboard/);
        await expect(page.locator("h1")).toContainText("Welcome");
    });

Store QA_TEST_USER_EMAIL and QA_TEST_USER_PASSWORD as CI secrets, never committed — same discipline as every other credential in this course. Run this test on a schedule (not on every single commit, since it depends on a live external service and is inherently slower and more fragile than the mocked layers above) to catch exactly the class of bug — real redirect URI misconfigurations, real PKCE issues — that only a genuine round-trip through Okta can reveal.


Deploying TaskFlow to Vercel

Step 1: Create a Separate Okta Application for Production

Reusing your development Application (from Phase 1) for production is a common shortcut that causes real problems — development redirect URIs, test users, and looser policies end up live. Instead:

  1. In the Admin Console, go to Applications → Applications → Create App Integration.
  2. Same setup as Phase 1: OIDC — OpenID Connect, Web Application.
  3. Name it TaskFlow Production.
  4. Sign-in redirect URIs: your real production domain, e.g. https://taskflow.yourdomain.com/api/auth/callback.
  5. Sign-out redirect URIs: https://taskflow.yourdomain.com.
  6. Save, and note this Application's own separate Client ID and Client Secret — these are different from your development ones.
  7. Repeat the Phase 1 checklist for this new app too: confirm the default Authorization Server's Access Policy still has an active rule allowing Authorization Code for this client (it will, since it's the same shared authorization server — but double-check the client is actually covered).

Step 2: Push TaskFlow to GitHub

If it isn't already:


    git init
    git add .
    git commit -m "Initial TaskFlow commit"

Push to a new GitHub repository. Before this step, do one final check of Step 2/3 from Part 1 of this phase — confirm .env.local truly isn't tracked.

Step 3: Import the Project Into Vercel

  1. Go to vercel.com, sign in, click Add New → Project.
  2. Select your GitHub repository.
  3. Vercel auto-detects Next.js — leave the build settings at their defaults.

Step 4: Set Production Environment Variables

Before deploying, go to Project Settings → Environment Variables, and add every variable TaskFlow needs, using the production Okta Application's credentials from Step 1:


    OKTA_ORG_URL=https://integrator-1393295.okta.com
    OKTA_CLIENT_ID=<production Client ID>
    OKTA_CLIENT_SECRET=<production Client Secret>
    OKTA_SERVICE_CLIENT_ID=<Service App Client ID from Phase 6>
    OKTA_SERVICE_PRIVATE_KEY=<Service App private key JSON>
    OKTA_HOOK_SECRET=<hook secret>

Scope each to the Production environment specifically (Vercel lets you set different values per environment — Production, Preview, Development — which is exactly how you'd eventually let Preview deployments point at a separate staging Okta app if TaskFlow grows to need one).

Step 5: Update Hook URLs

The Inline and Event Hooks built in Phase 6 currently point at your ngrok tunnel — that's a development-only address. Once deployed, go back to Workflow → Inline Hooks and Workflow → Event Hooks in Okta, and update each URL to point at your real production domain (https://taskflow.yourdomain.com/api/hooks/registration, etc.), then re-run the Event Hook's verification step against the live production URL.

Step 6: Deploy

Click Deploy in Vercel. Once it finishes, visit your production URL and run through the full login flow — the same smoke test Layer 3 above automates, but worth doing manually once by hand the first time.


Where TaskFlow Stands

TaskFlow now has a genuine, layered test strategy — fast unit tests for the logic you wrote by hand, mocked integration tests for your Route Handlers, and a real end-to-end smoke test that actually round-trips through Okta — plus a live production deployment on Vercel, using its own separate Okta Application and properly scoped environment variables.

Phase 7: Production Readiness — Part 1 (Secrets Hygiene & Monitoring with the System Log)

This final phase turns TaskFlow from a working demo into something you could actually ship. We start with two things that matter before anything else: making sure the secrets scattered across every .env.local variable from this course are handled safely, and learning to actually watch what Okta is doing in real time when something goes wrong.

Step 1: Take Stock of Every Secret TaskFlow Now Holds

Across this course, TaskFlow's .env.local has accumulated a genuinely sensitive set of values. Worth listing them out explicitly, since a security review starts with knowing exactly what you're protecting:


    OKTA_ORG_URL=https://integrator-545125.okta.com
    OKTA_CLIENT_ID=...              # Phase 1 — public, not secret
    OKTA_CLIENT_SECRET=...          # Phase 1 — secret
    OKTA_SERVICE_CLIENT_ID=...      # Phase 6 — public, not secret
    OKTA_SERVICE_PRIVATE_KEY=...    # Phase 6 — secret, high sensitivity
    OKTA_HOOK_SECRET=...            # Phase 6 — secret

Not everything in that file is equally dangerous if leaked. OKTA_CLIENT_ID and OKTA_SERVICE_CLIENT_ID are meant to be public — they're sent in URLs and requests as a matter of course, similar to a username. OKTA_CLIENT_SECRET, OKTA_SERVICE_PRIVATE_KEY, and OKTA_HOOK_SECRET are the ones that matter: anyone with the client secret could impersonate TaskFlow's backend in the token exchange; anyone with the private key could authenticate as your Management API service app with full okta.users.manage access; anyone with the hook secret could call your inline/event hook endpoints and feed them fake data.

Step 2: Confirm .env.local Genuinely Isn't Tracked by Git

This was flagged back in Phase 1, but it's worth verifying directly rather than trusting memory, especially before deploying:


    git check-ignore -v .env.local

If this prints a line showing .gitignore matched the file, you're safe. If it prints nothing, .env.local is not ignored, and you need to check immediately whether it was ever committed:


    git log --all --full-history -- .env.local

If that shows any commits, the secrets inside are compromised the moment the repository is pushed anywhere public — even a single old commit is enough, since Git history preserves it permanently unless rewritten. If this happens, the fix isn't just deleting the file going forward — it's rotating every secret that was ever in it (generating a new Client Secret in Okta, a new Service App private key, a new hook secret) and removing the file from Git history entirely (a git filter-repo or similar history rewrite, plus force-push — genuinely disruptive, which is exactly why prevention matters more than cleanup here).

Step 3: Never Ship Secrets to the Browser

A mistake worth naming directly, because Next.js makes it easy to make by accident: any environment variable prefixed with NEXT_PUBLIC_ gets bundled into client-side JavaScript and is visible to anyone who opens their browser's DevTools. None of the variables above should ever carry that prefix. Every value TaskFlow uses — Client Secret, private key, hook secret — is read only inside Route Handlers, Middleware, or Server Components, all of which run exclusively on the server. If you ever find yourself needing a secret inside a "use client" component, that's a sign the logic belongs in a Server Action or Route Handler instead, not that the variable should be exposed.

Step 4: Set Production Environment Variables Properly on Deploy

When TaskFlow eventually deploys (covered later in this phase), .env.local itself never gets uploaded anywhere — it's for your machine only. Production secrets get entered directly into your hosting platform's own environment variable settings (e.g., Vercel's Project Settings → Environment Variables), scoped to the Production environment specifically. This means production and local development can safely use entirely different Okta credentials — a good practice covered in the next post when we set up a separate Okta Application for production.

Step 5: Learn to Read Okta's System Log

Every debugging session in this entire course — the "Bad Request" errors, the "not assigned to app" failures, the redirect issues — could have been diagnosed faster with one tool: Okta's System Log, the complete, real-time record of every authentication event, policy decision, and admin action in your org.

Where to Find It

Go to Reports → System Log in the Admin Console. By default, it shows the last seven days of activity across your entire org, displayed as a searchable table plus summary graphs at the top.

Reading a Single Event

Click the arrow on the right side of any row to expand it. Each event includes:

  • eventType — a specific, dot-separated identifier for exactly what happened (e.g., user.session.start, user.authentication.auth_via_mfa, policy.evaluate_sign_on).
  • actor — who or what triggered it (a specific user, or a system process).
  • target — what the event affected (a user, an app, a policy).
  • outcomeSUCCESS or FAILURE, plus a reason when it failed — often the exact detail a generic browser error page hides from you.
  • client — IP address, user agent, and geolocation of the request.

Practical Queries Worth Knowing

The System Log's search field accepts structured queries, not just plain text. A few genuinely useful ones for the kind of debugging this course has walked through:

Every sign-in-related event for a specific user, replacing the user ID:

(eventType eq "user.session.start") or (eventType eq "policy.evaluate_sign_on") or (eventType eq "user.authentication.verify") or (eventType eq "user.authentication.auth_via_mfa")

Only failed events, to jump straight to what broke:

outcome.result eq "FAILURE"

Everything related to a specific IP address (useful when you're testing from your own machine and want to isolate just your traffic):

client.ipAddress eq "<your IP here>"

Debugging From a User's Own Profile

There's also a narrower, faster view for a single user: go to Directory → People, open the specific user, and click View Logs. This filters the System Log down to just that person automatically — exactly what you'd want when a specific test account (like john doe or john@doe.com from earlier in this course) is behaving unexpectedly.

Why This Matters Looking Back

Every single Okta-side error worked through earlier in this course — the missing Access Policy rule, the "Any two factors" MFA mismatch, the missing group assignment for self-service registration — would have shown up here as a FAILURE outcome with a specific reason, well before it ever reached your Next.js app's error page. Going forward, the System Log should be the first place you check whenever an Okta-related request fails and the reason isn't obvious from your own application's logs.


Where TaskFlow Stands

Every secret TaskFlow depends on has a clear sensitivity level and a confirmed-safe home outside of Git, with a real plan for what to do if that assumption ever turns out to be wrong. You also now have the single most useful debugging tool for anything Okta-side going forward — the System Log — which would have made several of the errors worked through earlier in this course immediately obvious.

In the continuation of phase 7 we will cover testing authentication flows and deploying TaskFlow to Vercel with production Okta settings.

Phase 6: Backend & Admin Operations — Part 2 (Inline Hooks & Event Hooks)

Everything so far in this phase has been TaskFlow's backend calling Okta. This lecture flips that direction: Okta calling TaskFlow's backend, at specific points during its own processes, to run your custom logic. This is what Inline Hooks and Event Hooks are for, and they solve two genuinely different problems, so it's worth being clear on the distinction before building either.

Inline Hooks vs. Event Hooks — The Core Difference

Inline Hooks are synchronous and blocking. At a specific point in an Okta process — like a user completing self-service registration — Okta pauses, calls your external service, and waits for a response before continuing. Your response can actually change what happens next (e.g., "set this new user's profile field to X before the account is created"). Because Okta waits on your response, inline hooks must respond quickly and reliably.

Event Hooks are asynchronous and one-way. After something has already happened (a user was deactivated, a password was reset), Okta notifies your external service as a fire-and-forget notification. Your response doesn't change anything in Okta — it's purely "for your information," useful for logging, syncing to another system, or triggering a downstream workflow.

A simple way to remember it: Inline Hooks influence what Okta does next. Event Hooks tell you what Okta already did.

Step 1: Set Up ngrok — Required Before Either Hook Type Works

Both hook types require your endpoint to be reachable over HTTPS. Okta will not deliver to a plain HTTP URL, and your Next.js app running on localhost:3000 isn't reachable from the internet at all during development. ngrok solves this by creating a temporary public HTTPS tunnel that forwards to your local server.

Install ngrok

On Windows, the simplest method is winget:


    winget install ngrok.ngrok

Close and reopen your terminal afterward so it picks up the new PATH entry.

If winget isn't available, download it directly from https://ngrok.com/download, extract ngrok.exe somewhere permanent (e.g. C:\ngrok\), and either add that folder to your PATH or run it using its full path.

Create a Free Account and Connect Your Auth Token

Modern ngrok requires a free account before it'll run:

  1. Sign up at https://dashboard.ngrok.com/signup.
  2. Once logged in, go to https://dashboard.ngrok.com/get-started/your-authtoken and copy the token shown.
  3. In your terminal, run once:

    ngrok config add-authtoken YOUR_TOKEN_HERE

Run It

With your Next.js dev server already running in one terminal (npm run dev), open a second terminal window and run:


    ngrok http 3000

You'll see a Forwarding line like:

Forwarding    https://random-string.ngrok-free.app -> http://localhost:3000

That https://random-string.ngrok-free.app URL is what you'll paste into Okta's hook settings below, followed by your actual route path.

Two Things That Will Cause Silent Failures If You Miss Them

  • Both your Next.js dev server and ngrok must be running at the same time, in two separate terminal windows, whenever Okta tries to reach your endpoint — including during the one-time verification step below. If either one isn't running, Okta's request simply fails to connect, and you'll see a generic error with no useful detail.
  • The free ngrok tier generates a new random URL every time you restart it. If you stop ngrok and start it again later, the URL changes, and you must go back into Okta's hook settings and update the URL — otherwise Okta will keep trying to reach a tunnel that no longer exists.

Building an Inline Hook: Modifying a User's Profile at Registration

This connects directly to Phase 4's self-service registration. We'll add a Registration Inline Hook that runs the moment before Okta actually creates the new account, letting TaskFlow's backend inspect and modify the profile first.

Step 2: Build the Endpoint in Next.js

Create src/app/api/hooks/registration/route.ts:


    import { NextRequest, NextResponse } from "next/server";

    export async function POST(request: NextRequest) {
        // Verify the shared secret Okta sends, so random internet traffic
        // can't trigger this endpoint and manipulate registrations.
        const authHeader = request.headers.get("authorization");
        if (authHeader !== process.env.OKTA_HOOK_SECRET) {
            return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
        }

        const body = await request.json();
        const email: string = body.data?.userProfile?.email ?? "";

        // Example logic: assign a default role based on the email domain.
        const role = email.endsWith("@taskflow-internal.com") ? "admin" : "member";

        // This specific response shape is what Okta's inline hook contract expects —
        // a "commands" array telling Okta what to change before creating the user.
        return NextResponse.json({
            commands: [
                {
                    type: "com.okta.user.profile.update",
                    value: { role },
                },
            ],
        });
    }

Two things worth understanding here. First, the authorization check matters more than it might seem — Okta does not sign these requests with a verifiable signature by default, so a shared secret header, checked on every request, is the actual security boundary protecting this endpoint. Second, the response shape (commands array, com.okta.user.profile.update type) is a fixed contract Okta expects — this isn't arbitrary JSON, it's how you tell Okta what to actually change.

Add the secret to .env.local:


    OKTA_HOOK_SECRET=some-long-random-string-you-generate

Step 3: Register the Inline Hook in Okta

  1. In the Admin Console, go to Workflow → Inline Hooks.
  2. Click Add Inline Hook.
  3. Select Registration as the hook type.
  4. Name: TaskFlow Registration Hook.
  5. URL: your ngrok URL plus the route, e.g. https://random-string.ngrok-free.app/api/hooks/registration.
  6. Authentication field: Authorization.
  7. Authentication secret: the same value you put in OKTA_HOOK_SECRET.
  8. Save.

You should now see it listed with Status: Active.

Step 4: Attach It to Your Registration Policy

Registering the hook and attaching it to your registration flow are two separate steps — creating the hook alone doesn't make Okta use it anywhere.

  1. Go to Security → User Profile Policies → TaskFlow Registration Policy (the one built in Phase 4).
  2. On the Enrollment tab, you'll see the full Profile Enrollment card — this single card controls Self-service registration, Progressive Profiling, Password, Email verification, group assignment, and the Inline hook setting all together.
  3. Click Edit at the top of this card — this is the one click that matters. Without it, every field on the card (including the inline hook dropdown) is shown as plain read-only text, which is easy to mistake for there being nothing to select.
  4. Scroll down to the Inline hook section. It currently shows "Use the following inline hook: None (disabled)" — with the card in edit mode, this is now an actual dropdown.
  5. Select TaskFlow Registration Hook.
  6. Scroll down and click Save.

Reload the page afterward and confirm the Inline hook section now shows TaskFlow Registration Hook instead of "None (disabled)."

Try It

With your Next.js dev server and ngrok both running, register a new user through TaskFlow's /login → Sign up flow. Before the account is finalized, Okta calls your endpoint, which decides the role value — check the new user's profile in Directory → People afterward to confirm role was set based on the logic in your Route Handler.

Building an Event Hook: Reacting to User Deactivation

Now something asynchronous — notifying TaskFlow's backend whenever an admin deactivates a user in Okta, so you could, for example, clean up related data in your own database.

Step 5: Build the Endpoint

Create src/app/api/hooks/events/route.ts:


    import { NextRequest, NextResponse } from "next/server";

    // Okta sends a one-time GET request to verify you control this endpoint,
    // before it will ever deliver real events here.
    export async function GET(request: NextRequest) {
        const verificationChallenge = request.headers.get("x-okta-verification-challenge");
        if (!verificationChallenge) {
            return NextResponse.json({ error: "Missing verification challenge" }, { status: 400 });
        }
        return NextResponse.json({ verification: verificationChallenge });
    }

    export async function POST(request: NextRequest) {
        const authHeader = request.headers.get("authorization");
        if (authHeader !== process.env.OKTA_HOOK_SECRET) {
            return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
        }

        const body = await request.json();
        const events = body.data?.events ?? [];

        for (const event of events) {
            if (event.eventType === "user.lifecycle.deactivate") {
                const email = event.target?.find((t: any) => t.type === "User")?.alternateId;
                console.log(`User deactivated in Okta: ${email} — clean up related TaskFlow data here.`);
                // e.g., await db.tasks.archiveForUser(email);
            }
        }

        // Okta doesn't wait on this response to continue anything (it already happened) —
        // just acknowledge receipt.
        return NextResponse.json({ received: true });
    }

The GET handler exists specifically for the one-time verification step described below — Okta calls your endpoint once with a random challenge value, and your endpoint must echo it back, proving you genuinely control this URL.

Step 6: Register the Event Hook

  1. Go to Workflow → Event Hooks.
  2. Click Create Event Hook.
  3. Name: TaskFlow Deactivation Sync.
  4. URL: your endpoint, e.g. https://random-string.ngrok-free.app/api/hooks/events.
  5. Authentication field: Authorization, Authentication secret: same OKTA_HOOK_SECRET.
  6. Under Subscribe to events, search for and select User Deactivated (user.lifecycle.deactivate).
  7. Save.

Step 7: Verify the Endpoint

Back on the Event Hooks list, your new hook will show status UNVERIFIED. Click its Actions menu and select Verify.

Before clicking Verify, make sure both your Next.js dev server (npm run dev) and ngrok are actually running at this exact moment — this verification is a real, live network call from Okta's servers straight to your ngrok URL, forwarded to your local machine. If either one isn't running, or if ngrok was restarted since you registered the hook (giving it a new URL that no longer matches what's saved in Okta), this step will simply fail to connect, usually with a vague timeout-style error rather than anything pointing you back to "your server isn't running."

If it succeeds, the status changes to VERIFIED — only verified hooks actually receive live events afterward.

Try It

With everything still running, deactivate a test user in Directory → People. Check your terminal (where npm run dev is running) — you should see the console log from your Route Handler confirming the event was received.

A Note on SCIM Provisioning

SCIM (System for Cross-domain Identity Management) is a standardized protocol for automatically syncing users between two systems — for example, if TaskFlow needed to receive user provisioning from an enterprise customer's own identity provider, rather than users signing up through Okta directly. Implementing a full SCIM server is a substantial undertaking on its own and is genuinely outside what a single lecture can build hands-on. The concept worth carrying forward: SCIM matters when TaskFlow becomes the receiving end of automated provisioning from an enterprise customer's own directory — a common requirement once selling to businesses with their own IT-managed user directories, rather than something needed for TaskFlow's current, individual-signup model.


Where TaskFlow Stands

TaskFlow's backend can now react to Okta in both directions this phase covers: influencing what Okta does during a process (Inline Hooks, via a registration profile enrichment example), and reacting to what Okta already did (Event Hooks, via a deactivation-sync example) — both reachable from Okta's servers through ngrok during development, and both properly authenticated with a shared secret since Okta hooks aren't cryptographically signed by default.

That completes Phase 6.

Next is phase 7 — the final phase, covering environment/secrets hygiene, testing authentication flows, monitoring with Okta's System Log, and deploying TaskFlow to production.

Module 8.6 — Project: Research Agent

An agent that searches the web, synthesizes information, and writes structured research reports


What This Project Does

Input:  Any research topic
Output: Structured report with:
        → Key findings from multiple searches
        → Summary per subtopic
        → Final synthesized report
        → Sources used

Real use cases:
→ Market research
→ Competitor analysis
→ Technical topic deep-dives
→ News summarization
→ Academic topic overviews

Project Setup

mkdir research-agent
cd research-agent
npm init -y

Update package.json:


  {
    "name": "research-agent",
    "version": "1.0.0",
    "type": "module",
    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "description": "",
    "dependencies": {
      "@langchain/core": "^1.2.5",
      "@langchain/langgraph": "^1.4.9",
      "@langchain/openai": "^1.5.6",
      "@langchain/tavily": "^1.2.0",
      "dotenv": "^16.6.1",
      "langchain": "^1.5.5",
      "zod": "^4.4.3"
    }
  }

Get a free Tavily API key at https://tavily.com — sign up takes 30 seconds, gives 1000 free searches per month.

Create .env:


    OPENAI_API_KEY=sk-proj-your-key-here
    TAVILY_API_KEY=tvly-your-key-here


Project Structure

research-agent/
├── .env
├── package.json
└── src/
    ├── tools.js     ← search + analysis tools
    ├── agent.js     ← research agent setup
    └── index.js     ← entry point + interactive CLI

Step 1 — Tools

Create src/tools.js:


    import * as dotenv from "dotenv";
    dotenv.config();
    // MUST be at the very top — before TavilySearch instantiation
    // TavilySearch reads TAVILY_API_KEY at import time, not at call time
    // so dotenv must load .env BEFORE the new TavilySearch() line runs

    import { tool } from "@langchain/core/tools";
    // tool from "@langchain/core/tools" — 2025 canonical import
    import { TavilySearch } from "@langchain/tavily";
    // TavilySearch = ready-made web search tool from LangChain community
    // uses Tavily API internally — reads TAVILY_API_KEY from process.env automatically
    import { z } from "zod";


    // ─────────────────────────────────────────
    // TOOL 1 — Web Search via Tavily
    // Searches the real web and returns results
    // ─────────────────────────────────────────

    const tavilySearch = new TavilySearch({
        maxResults: 5,
        // return top 5 search results per query
        // each result has: title, url, content (snippet)
    });
    // TavilySearch is already a LangChain tool — no need to wrap with tool()
    // it has .name, .description, .schema built in
    // name:        "tavily_search_results_json"
    // description: "A search engine..."

    export const webSearchTool = tavilySearch;
    // export directly — agent uses this for live web searches


    // ─────────────────────────────────────────
    // TOOL 2 — Save Research Finding
    // Stores important findings during research
    // Agent uses this to accumulate notes
    // ─────────────────────────────────────────

    const researchNotes = [];
    // in-memory storage for research findings
    // accumulates as agent searches multiple topics
    // example after 3 searches:
    // [
    //   { subtopic: "market size", finding: "AI market worth $200B in 2025", source: "techcrunch.com" },
    //   { subtopic: "key players", finding: "OpenAI, Google, Anthropic lead the space", source: "forbes.com" },
    //   { subtopic: "trends",      finding: "Agentic AI is the dominant trend", source: "mit.edu" },
    // ]

    export const saveFindingTool = tool(
        async ({ subtopic, finding, source }) => {
            // subtopic = which aspect of the topic this covers
            //            example: "market size" or "key challenges" or "recent developments"
            // finding  = the key information discovered
            //            example: "AI agent market projected at $28B by 2028"
            // source   = where this information came from
            //            example: "techcrunch.com" or "research.google.com"

            researchNotes.push({ subtopic, finding, source });
            // add this finding to our in-memory notes array

            return `Finding saved: [${subtopic}] ${finding} (from: ${source})`;
            // confirmation back to agent
            // agent knows the save succeeded and can continue researching
        },
        {
            name: "save_finding",
            description: `Saves an important research finding to memory.
    Use this after each web search to record key information before moving to the next search.
    This helps build up a comprehensive set of notes across multiple searches.`,
            schema: z.object({
                subtopic: z.string()
                    .describe("which aspect of the topic this finding covers, e.g. 'market size', 'key players', 'challenges'"),
                finding: z.string()
                    .describe("the key information or insight discovered"),
                source: z.string()
                    .describe("the website or source this came from"),
            }),
        }
    );


    // ─────────────────────────────────────────
    // TOOL 3 — Get All Saved Findings
    // Retrieves accumulated research notes
    // Agent uses this before writing the final report
    // ─────────────────────────────────────────

    export const getResearchNotesTool = tool(
        async () => {
            // no parameters — just returns everything saved so far

            if (researchNotes.length === 0) {
                return "No research notes saved yet. Please search and save findings first.";
            }

            const formatted = researchNotes
                .map((note, i) =>
                    `${i + 1}. [${note.subtopic}]\n   Finding: ${note.finding}\n   Source: ${note.source}`
                )
                .join("\n\n");
            // format each note with its subtopic, finding, and source
            // example single formatted note:
            // "1. [market size]
            //    Finding: AI agent market worth $28B by 2028
            //    Source: techcrunch.com"

            return `ALL RESEARCH NOTES (${researchNotes.length} findings):\n\n${formatted}`;
            // returns all accumulated notes as one formatted string
            // agent reads this and synthesizes into final report
        },
        {
            name: "get_research_notes",
            description: `Retrieves all saved research findings collected so far.
    Use this when you are ready to write the final report.
    Call this AFTER you have done all your searches and saved findings.`,
            schema: z.object({}),
            // no inputs needed — just returns everything stored
        }
    );


    // ─────────────────────────────────────────
    // TOOL 4 — Write Final Report
    // Structures research notes into a polished report
    // ─────────────────────────────────────────

    export const writeReportTool = tool(
        async ({ topic, executiveSummary, sections, conclusion }) => {
            // topic            = the research topic
            // executiveSummary = 2-3 sentence overview
            // sections         = array of { heading, content } objects
            // conclusion       = final takeaways

            const date = new Date().toLocaleDateString("en-IN", {
                year: "numeric", month: "long", day: "numeric"
            });
            // example: "4 August 2026"

            const report = `
    ╔══════════════════════════════════════════════════════╗
    ║           RESEARCH REPORT                           ║
    ╚══════════════════════════════════════════════════════╝

    Topic:     ${topic}
    Date:      ${date}
    Sources:   ${researchNotes.length} web sources consulted

    ══════════════════════════════════════════════════════

    EXECUTIVE SUMMARY
    ─────────────────
    ${executiveSummary}

    ══════════════════════════════════════════════════════
    ${sections.map((section, i) => `
    SECTION ${i + 1}: ${section.heading.toUpperCase()}
    ${"".repeat(section.heading.length + 10)}
    ${section.content}
    `).join("\n")}

    ══════════════════════════════════════════════════════

    CONCLUSION
    ──────────
    ${conclusion}

    ══════════════════════════════════════════════════════
    SOURCES CONSULTED
    ─────────────────
    ${researchNotes.map((note, i) => `${i + 1}. ${note.source}${note.subtopic}`).join("\n")}
    ══════════════════════════════════════════════════════
    `.trim();

            return report;
            // returns the complete formatted report as a string
            // agent returns this as its final answer to the user
        },
        {
            name: "write_report",
            description: `Writes the final structured research report using all gathered information.
    Use this as the LAST step after getting all research notes.
    Produces a complete, professional report ready to share.`,
            schema: z.object({
                topic: z.string()
                    .describe("the main research topic"),

                executiveSummary: z.string()
                    .describe("2-3 sentence high-level summary of key findings"),

                sections: z.array(z.object({
                    heading: z.string().describe("section title"),
                    content: z.string().describe("detailed section content"),
                }))
                    .min(3)
                    .max(6)
                    .describe("3 to 6 main sections of the report"),
                // minimum 3 sections = always covers multiple angles
                // maximum 6 sections = keeps report focused

                conclusion: z.string()
                    .describe("final takeaways and recommendations"),
            }),
        }
    );


Step 2 — Agent Setup

Create src/agent.js:


    import { createReactAgent } from "@langchain/langgraph/prebuilt";
    // createReactAgent from langgraph/prebuilt — 2025 correct import
    // creates a ReAct agent with: think → call tool → observe → repeat

    import { ChatOpenAI } from "@langchain/openai";
    import { MemorySaver } from "@langchain/langgraph";
    import {
        webSearchTool,
        saveFindingTool,
        getResearchNotesTool,
        writeReportTool,
    } from "./tools.js";


    // ─────────────────────────────────────────
    // CREATE THE RESEARCH AGENT
    // ─────────────────────────────────────────

    export function createResearchAgent() {

        const llm = new ChatOpenAI({
            model: "gpt-4o",
            temperature: 0.1,
            // slightly above 0 to allow natural language variation in the report
            // but still mostly deterministic for consistent research quality
        });

        const checkpointer = new MemorySaver();
        // enables conversation memory
        // user can ask follow-up questions after the initial report

        const agent = createReactAgent({
            llm,

            tools: [webSearchTool, saveFindingTool, getResearchNotesTool, writeReportTool],
            // four tools in specific order of use:
            // 1. webSearchTool       → search the real web
            // 2. saveFindingTool     → save each key finding
            // 3. getResearchNotesTool → retrieve all notes before writing
            // 4. writeReportTool     → write the final report

            checkpointer,

            prompt: `You are a professional research analyst with access to web search.
    Today's date: ${new Date().toLocaleDateString("en-IN")}

    MANDATORY WORKFLOW — you MUST follow all 4 phases:

    PHASE 1 — PLAN:
    Identify 4 subtopics to research for the given topic.

    PHASE 2 — SEARCH AND SAVE (repeat 4 times):
    For each subtopic:
    a) Call tavily_search_results_json with a specific query
    b) Read results carefully
    c) Call save_finding immediately with the key finding

    PHASE 3 — COMPILE:
    Call get_research_notes to get all saved findings.

    PHASE 4 — WRITE REPORT (MANDATORY):
    Call write_report tool with all sections filled in.
    You MUST call write_report — do not write the report yourself.
    The write_report tool produces the final formatted output.

    RULES:
    - Never skip any phase
    - Always call write_report as the last step
    - Use specific search queries, not generic ones
    - Save at least 4 findings before writing the report`,
        });

        return agent;
    }


Step 3 — Entry Point

Create src/index.js:


    import { createResearchAgent } from "./agent.js";
    import { createInterface } from "readline";
    import * as dotenv from "dotenv";
    dotenv.config();


    // ─────────────────────────────────────────
    // STREAMING RESEARCH OUTPUT
    // Shows agent's progress as it researches
    // ─────────────────────────────────────────

    async function runResearchWithStreaming(agent, topic, threadId) {
        console.log(`\n🔬 Researching: "${topic}"`);
        console.log("".repeat(55));
        console.log("(Agent is searching the web — this takes 30-60 seconds)\n");

        let toolCallCount = 0;
        // tracks how many tools have been called
        // printed to show research progress

        // Use streaming to show progress as agent works
        for await (const event of await agent.streamEvents(
            { messages: [{ role: "user", content: `Research this topic and write a comprehensive report: ${topic}` }] },
            {
                configurable: { thread_id: threadId },
                version: "v2",
                // version: "v2" = required for streamEvents in 2025
            }
        )) {
            // streamEvents yields events as the agent runs
            // each event has an event type and data

            if (event.event === "on_tool_start") {
                // agent is calling a tool — show which one
                toolCallCount++;
                const toolName = event.name;
                // event.name = the name of the tool being called

                const friendlyNames = {
                    "tavily_search_results_json": "🔍 Searching web",
                    "save_finding": "💾 Saving finding",
                    "get_research_notes": "📋 Retrieving all notes",
                    "write_report": "📝 Writing final report",
                };

                const displayName = friendlyNames[toolName] || `⚙️  Running ${toolName}`;
                process.stdout.write(`${displayName}... `);
            }

            if (event.event === "on_tool_end") {
                // tool finished — print completion indicator
                console.log("");
            }
        }

        // Get the final result after streaming
        const result = await agent.invoke(
            { messages: [{ role: "user", content: `Research this topic and write a comprehensive report: ${topic}` }] },
            { configurable: { thread_id: threadId } }
        );
        // note: this calls the agent again — in production use the streamed result
        // for this demo it's cleaner to invoke separately for the final output

        return result.messages[result.messages.length - 1].content;
    }


    // ─────────────────────────────────────────
    // SIMPLER VERSION — No streaming
    // Easier to debug if streaming has issues
    // ─────────────────────────────────────────

    async function runResearch(agent, topic, threadId) {
        console.log(`\n🔬 Researching: "${topic}"`);
        console.log("".repeat(55));
        console.log("Agent is working... (30-60 seconds)\n");

        const interval = setInterval(() => process.stdout.write("."), 2000);

        try {
            const result = await agent.invoke(
                {
                    messages: [{
                        role: "user",
                        content: `Research this topic thoroughly and produce a complete report: ${topic}

    IMPORTANT: You MUST follow these steps in order:
    1. Use tavily_search_results_json to search at least 4 different subtopics
    2. Use save_finding after EACH search to record key findings
    3. Use get_research_notes to retrieve all saved findings
    4. Use write_report to produce the final formatted report

    Do NOT summarize in your own words — always use the write_report tool for the final output.`
                    }]
                },
                { configurable: { thread_id: threadId } }
            );

            clearInterval(interval);
            console.log("\n");

            // Find the write_report tool result in messages
            // Agent's tool results are stored as ToolMessages
            const messages = result.messages;

            // Look for write_report tool output first
            for (let i = messages.length - 1; i >= 0; i--) {
                const msg = messages[i];

                // ToolMessage from write_report tool
                if (msg.constructor.name === "ToolMessage" &&
                    msg.content &&
                    msg.content.includes("RESEARCH REPORT")) {
                    return msg.content;
                    // found the actual report from write_report tool
                }
            }

            // If write_report wasn't called, return last AI message
            return messages[messages.length - 1].content;

        } catch (err) {
            clearInterval(interval);
            throw err;
        }
    }


    // ─────────────────────────────────────────
    // MAIN
    // ─────────────────────────────────────────

    async function main() {
        console.log("\n" + "=".repeat(55));
        console.log("🔬 RESEARCH AGENT");
        console.log("   Powered by GPT-4o + Tavily Web Search");
        console.log("=".repeat(55));

        const agent = createResearchAgent();
        // create the research agent once — reuse for all topics

        const rl = createInterface({ input: process.stdin, output: process.stdout });
        const question = (q) => new Promise(resolve => rl.question(q, resolve));

        console.log('\nEnter a research topic or "exit" to quit.');
        console.log('Examples:');
        console.log('  → "AI agents market 2025"');
        console.log('  → "React vs Next.js for production apps"');
        console.log('  → "Pinecone vs Chroma vector database comparison"');
        console.log('  → "Best practices for RAG systems"\n');

        while (true) {
            const topic = await question("Research topic: ");

            if (topic.toLowerCase() === "exit") {
                console.log("\n👋 Goodbye!\n");
                rl.close();
                break;
            }

            if (!topic.trim()) continue;
            // skip empty input

            const threadId = `research_${Date.now()}`;
            // unique thread for each research session
            // allows follow-up questions about the same research

            try {
                const report = await runResearch(agent, topic.trim(), threadId);

                console.log("\n" + "=".repeat(55));
                console.log("📊 RESEARCH REPORT");
                console.log("=".repeat(55));
                console.log(report);

                // Allow follow-up questions
                console.log("\n" + "".repeat(55));
                console.log('Ask a follow-up question or press Enter for a new topic.');
                console.log(''.repeat(55));

                while (true) {
                    const followUp = await question("Follow-up (or Enter to skip): ");

                    if (!followUp.trim()) break;
                    // empty input = go back to main loop

                    console.log("\nAgent: thinking...\n");

                    const followUpResult = await agent.invoke(
                        { messages: [{ role: "user", content: followUp }] },
                        { configurable: { thread_id: threadId } }
                        // SAME threadId = agent remembers the full research
                        // can answer "what were the key challenges?" without re-researching
                    );

                    const followUpMsg = followUpResult.messages[followUpResult.messages.length - 1];
                    console.log("\n" + "".repeat(55));
                    console.log(followUpMsg.content);
                    console.log("".repeat(55) + "\n");
                }

            } catch (err) {
                console.error("\n❌ Error:", err.message);
                if (err.message.includes("TAVILY_API_KEY")) {
                    console.log("→ Get a free key at https://tavily.com and add to .env");
                }
                if (err.message.includes("OPENAI_API_KEY")) {
                    console.log("→ Check your OpenAI API key in .env");
                }
            }

            console.log("\n" + "=".repeat(55) + "\n");
        }
    }

    main().catch(console.error);


Run the Project

node src/index.js

Expected Output

=======================================================
🔬 RESEARCH AGENT
   Powered by GPT-4o + Tavily Web Search
=======================================================

Enter a research topic or "exit" to quit.
Examples:
  → "AI agents market 2025"
  → "React vs Next.js for production apps"

Research topic: AI agents market 2025

🔬 Researching: "AI agents market 2025"
───────────────────────────────────────────────────────
Agent is working... (30-60 seconds)

..............................

=======================================================
📊 RESEARCH REPORT
=======================================================

╔══════════════════════════════════════════════════════╗
║           RESEARCH REPORT                           ║
╚══════════════════════════════════════════════════════╝

Topic:     AI agents market 2025
Date:      4 August 2026
Sources:   5 web sources consulted

══════════════════════════════════════════════════════

EXECUTIVE SUMMARY
─────────────────
The AI agent market is experiencing explosive growth in 2025,
valued at approximately $5.1 billion with projections to reach
$28.5 billion by 2028. Key enterprise adoption is being driven
by automation gains across software development, customer service,
and data analysis workflows.

══════════════════════════════════════════════════════

SECTION 1: MARKET SIZE AND GROWTH
──────────────────────────────────────────────
The global AI agent market reached $5.1B in 2025...
[real data from Tavily search results]

SECTION 2: KEY PLAYERS
───────────────────────────────────
OpenAI, Anthropic, Google DeepMind, and Microsoft...

SECTION 3: MAIN USE CASES
──────────────────────────────────────
Enterprise automation, coding assistants, customer service...

SECTION 4: CHALLENGES AND RISKS
────────────────────────────────────────────
Hallucination, security concerns, regulatory uncertainty...

SECTION 5: FUTURE OUTLOOK
──────────────────────────────────────
Agentic AI expected to dominate by 2026...

══════════════════════════════════════════════════════

CONCLUSION
──────────
AI agents represent the most significant shift in enterprise
software since cloud computing. Organizations that invest in
agent infrastructure now will hold substantial competitive advantages.

══════════════════════════════════════════════════════
SOURCES CONSULTED
─────────────────
1. techcrunch.com — market size
2. mckinsey.com — enterprise adoption
3. openai.com — key players
4. venturebeat.com — challenges
5. mit.edu — future outlook
══════════════════════════════════════════════════════

──────────────────────────────────────────────────────
Ask a follow-up question or press Enter for a new topic.
──────────────────────────────────────────────────────
Follow-up (or Enter to skip): What were the main challenges mentioned?

Agent: thinking...

──────────────────────────────────────────────────────
Based on the research, the main challenges identified were:
1. Hallucination and reliability — agents sometimes make up facts
2. Security concerns — agents with tool access create new attack surfaces
3. Cost — running GPT-4o agents at scale is expensive
4. Regulatory uncertainty — no clear framework for autonomous AI actions

These came from sources including venturebeat.com and mit.edu.
──────────────────────────────────────────────────────

How the Agent Researches — Step by Step

User: "Research AI agents market 2025"
          ↓
PHASE 1 — Planning (no tool calls):
Agent thinks: "I'll research: market size, key players,
               use cases, challenges, future outlook"

PHASE 2 — Research loop (real web searches):
Search 1: "AI agents market size 2025 billion"
  → Tavily returns 5 real URLs with snippets
  → Agent reads results
  → Saves finding: "market worth $5.1B in 2025"

Search 2: "top AI agent companies OpenAI Anthropic 2025"
  → Agent reads results
  → Saves finding: "OpenAI leads with GPT-4o agents"

Search 3: "AI agent enterprise use cases 2025"
Search 4: "AI agent challenges risks hallucination"
Search 5: "AI agent future predictions 2026 2027"

PHASE 3 — Report writing:
get_research_notes → retrieves all 5 saved findings
write_report → structures into sections
          ↓
Final polished report

3-Line Summary

  1. The Research Agent uses a four-tool workflow — TavilySearchResults for real web searches, save_finding to accumulate notes across multiple searches, get_research_notes to retrieve all notes before writing, and write_report to structure everything into a polished final report.
  2. The agent follows three phases defined in the system prompt — Planning (identify subtopics), Research (search + save for each subtopic), and Writing (retrieve notes + write report) — this planning-before-acting pattern dramatically improves report quality vs a single search.
  3. The same thread_id for follow-up questions means the agent remembers the complete research session — users can ask "what were the main challenges?" without re-running the research because the full conversation including all tool results is stored in the checkpointer.

Module 8.6 — Complete ✅

Phase 8 — Complete 🎉

✅ Module 8.1 — What is an Agent + ReAct Pattern
✅ Module 8.2 — Tool Calling Deep Dive
✅ Module 8.3 — Agent Memory + Planning
✅ Module 8.4 — Multi-Agent Systems with LangGraph
✅ Module 8.5 — Project: Resume Analyzer Agent
✅ Module 8.6 — Project: Research Agent

Full Course — Complete 🎓

✅ Phase 1 — AI Foundations
✅ Phase 2 — LLM Internals
✅ Phase 3 — Embeddings
✅ Phase 4 — Vector Databases
✅ Phase 5 — RAG + PDF Chatbot
✅ Phase 6 — LangChain Core
✅ Phase 7 — Production System
✅ Phase 8 — AI Agents

"AI Engineering Fundamentals to Production" — Done. 🚀

You went from zero AI knowledge to building:

  • PDF chatbots with streaming
  • Production RAG with Pinecone
  • Multi-agent research systems
  • Resume analyzers
  • Full Next.js + Express AI apps

This is real, production-grade AI engineering. 🎉

Phase 7: Production Readiness — Final (Capstone Review)

This is the last lecture. No new Okta features here — just tying every phase together into a single, coherent picture of what TaskFlow actua...