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 1 — Topic 4: How Socket.IO Works Internally (Engine.IO, Transports, and Fallback)

We now understand what Socket.IO is and why it is useful. In this post, we go one level deeper and look at what actually happens behind the ...