Build a Discord Webhook Help Bridge

Build a secure local form that sends learner help requests to a Discord forum.

Introduction

30 Second Summary

Getting stuck feels worse when a help page asks you to explain your whole project again. A generic community link leaves the hardest part of asking for help on you.

In this project, you will build a local help bridge that captures the project context behind a learner's blocker. A server-side relay will send each request to a dedicated Discord Forum post through a protected Discord Incoming Webhook.

What You'll Build

Your demo turns a browser form submission into a structured Discord discussion with a direct route to peer support.

By the end of this project, you'll have:

  • A learner-facing contextual help form that captures the project, step, problem, expected result, and attempted fixes behind each blocker.
  • A dedicated forum post that gives peers the learning context they need to respond.
  • A protected submission flow that keeps the webhook URL away from the browser. Learner-generated mentions remain suppressed.
  • Secret Mission: Build a triage workflow that moves support posts from Needs help to Resolved.

Are there any prerequisites?

This project assumes Discord administrator access plus NextWork codebase access. The guide includes the missing setup paths for Discord Community, Node.js v24.20.0, and Visual Studio Code on macOS.

Before We Start

This first checkpoint locks in the learner moment you want to improve before any hands-on work begins. Your answer gives the community bridge a clear test of success.

Set Up the Local Prototype Workspace

Your community handoff needs a stable local place to run before it can reach Discord. A pinned workspace removes version differences that could blur the learner experience you want to test.

The prototype uses Node.js v24.20.0 in macOS Terminal. This exact runtime keeps every later command predictable.

Visual Studio Code gives you one place to manage the prototype files. You will also connect it to Terminal through the code command.

In this step, get ready to:
  • Prepare Visual Studio Code for the local prototype.
  • Confirm the required Node.js runtime.
  • Launch the empty prototype workspace from Terminal.
Install Visual Studio Code on macOS

Visual Studio Code is the editor you will use for every prototype file. Start by checking whether it is already available on your Mac.

  • Press Cmd+Space to open the macOS search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to try opening the app.

✔️ Visual Studio Code opens

Visual Studio Code is ready for the prototype. Leave the app open because you will configure its Terminal command shortly.

ⓧ Visual Studio Code is missing

The macOS disk image contains the app you need. Installing it in Applications makes it available from the system search bar.

  • Use the official macOS setup page to download the Visual Studio Code disk image.
  • Open the downloaded .dmg file.
  • Drag Visual Studio Code.app into Applications.
  • Open Visual Studio Code.app from Applications.

Visual Studio Code is now installed. You should see its application window on your Mac.

Visual Studio Code Won't Open?

Confirm that Visual Studio Code.app is inside Applications. Reopen it from that folder.

Use help me troubleshoot the Visual Studio Code installation before sharing any remaining macOS message with the NextWork community.

Verify the required Node.js version

Node.js runs the local web server you will build in the next step. This project pins the runtime to v24.20.0 so your environment matches the prototype.

  • Press Cmd+Space to open the macOS search bar.
  • Type Terminal into the search bar.
  • Press Enter to open Terminal.
  • Check your installed Node.js version by running this command:
node --version

What Does This Check Do?

  • The node --version command asks the active Node.js installation to print its version.
  • The required output for this project is v24.20.0.
  • Choose the tab that matches the output in Terminal.

✔️ I see the required version

Your Mac already has the pinned Node.js runtime. That removes the biggest environment variable from the prototype.

ⓧ I see another version

A different Node.js version could behave differently from the tested runtime. Install the pinned release before continuing.

  • Download the official Node.js v24.20.0 macOS installer.
  • Open the downloaded package.
  • Complete the package installation with the default options.
  • Restart Terminal after the installation completes.

ⓧ Command not found

Terminal cannot find a Node.js installation yet. The official macOS package adds the runtime and its command to your Mac.

  • Download the official Node.js v24.20.0 macOS installer.
  • Open the downloaded package.
  • Complete the package installation with the default options.
  • Restart Terminal after the installation completes.
  • Confirm that Terminal now uses the pinned runtime by running the version check again:
node --version

What Does the Second Check Prove?

The new Terminal session reloads your command environment. Seeing the pinned version confirms that the node command now points to the runtime this project expects.

You should now see v24.20.0 in Terminal. Your local runtime is ready.

Still Seeing the Wrong Version?

Restart Terminal before checking again. If the output still differs, reopen the official installer and confirm that it completes successfully.

Use help me diagnose the Node.js version mismatch before sharing any remaining installation details with the NextWork community.

Open the workspace from Terminal

The code command lets Terminal open its current folder in Visual Studio Code. This one-time setting is tucked inside the Command Palette.

  • Switch back to Visual Studio Code.
  • Press Cmd+Shift+P to open the Command Palette.

You will see the Command Palette at the top of the Visual Studio Code window. It searches editor actions by name.

  • Enter Shell Command: Install 'code' command in PATH into the Command Palette.
  • Select the matching command.

Visual Studio Code confirms that the shell command is installed. New Terminal windows can now find code through PATH.

Your workspace will be an empty folder on the Desktop. Keeping it there makes the prototype easy to find during testing.

  • Close the Terminal window you used for the Node.js checks.
  • Click the desktop to switch to Finder.
  • Press Shift-Command-N to create a new folder.

You will see a new empty folder on the Desktop with its name selected.

  • Type nextwork-help-bridge as the folder name.
  • Press Return to save the name.

The nextwork-help-bridge folder is now visible on your Desktop.

  • Double-click the nextwork-help-bridge folder.

Finder shows the empty folder. This is the workspace that will hold the local prototype.

  • Select View in the Finder menu bar.
  • Select Show Path Bar.

You will see the folder path along the bottom of the Finder window.

  • Control-click the nextwork-help-bridge folder in the path bar.
  • Select Open in Terminal.

A new Terminal window opens with nextwork-help-bridge as its current folder.

Before you run the final check, what two signs should prove that the runtime and workspace are both ready?

  • Verify the runtime and launch the current workspace by running these commands:
node --version
code .

What Do These Commands Prove?

  • The first command confirms that Terminal still uses Node.js v24.20.0.
  • The second command asks Visual Studio Code to open Terminal's current folder.

You should see v24.20.0 in Terminal. Visual Studio Code should open the empty nextwork-help-bridge folder.

Workspace Won't Open?

If Terminal cannot find code, repeat the Command Palette setup in Visual Studio Code. Open a fresh Terminal window before trying again.

If Visual Studio Code opens a different folder, return to Finder and use Open in Terminal from the nextwork-help-bridge path bar entry.

Use help me debug the workspace launch if the workspace still does not open correctly.

Your pinned local workspace is ready. Next, you will build the learner-facing help form and test the missing community connection.

Build the Help Form

Your Node.js workspace is ready. Now the support idea needs a learner-facing experience that you can test.

A visible form proves the in-app experience before any community integration exists. Its deliberate disconnected response exposes the server-side gap that the Discord relay must solve.

In this step, get ready to:
  • Create a learner help form that captures the project context.
  • Serve the form from a local Node.js server.
  • Submit a realistic blocker and observe the disconnected response.
Build the learner help form

A useful support request gives peers enough context to reproduce the blocker. The HTML form collects that context while reminding learners to keep sensitive information private.

  • In the Visual Studio Code file sidebar, create index.html inside nextwork-help-bridge.
  • Paste this page structure into index.html:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>NextWork Community Help</title>
  </head>
  <body>
    <main>
      <h1>Ask the NextWork community</h1>
      <p>Do not include passwords, API keys, private repository links, personal data, or customer information. Your request will be posted to Discord.</p>
      <form id="help-form">
        <label>Project <input name="project" maxlength="80" required /></label>
        <label>Step <input name="step" maxlength="80" required /></label>
        <label>What happened? <textarea name="problem" maxlength="400" required></textarea></label>
        <label>What did you expect? <textarea name="expected" maxlength="400" required></textarea></label>
        <label>What have you tried? <textarea name="tried" maxlength="400" required></textarea></label>
        <button id="submit-button" type="submit">Ask the community</button>
      </form>
      <p id="status" role="status" aria-live="polite"></p>
      <a id="forum-link" href="#" target="_blank" rel="noopener noreferrer" hidden>Open the Discord help forum</a>
    </main>
  </body>
</html>

What does this page capture?

  • The project field identifies the learner's current project.
  • The step field pinpoints where the learner became blocked.
  • The three text areas capture the problem from different angles.
  • The privacy reminder keeps credentials out of community posts.
  • The status element provides submission feedback.
  • The hidden forum-link element reserves a direct route to the help forum.
  • Save index.html.
  • Control-click index.html in the Visual Studio Code file sidebar.
  • Choose the option that reveals the file in Finder.
  • Double-click index.html in Finder.

You should see the NextWork community heading above five required fields. You should also see the privacy reminder above the form.

Form missing fields?

Confirm that every label sits inside help-form. Check that the closing </form> appears after the button.

If the browser displays the HTML source as text, confirm that the filename ends with .html. Help me find why my HTML form is not rendering correctly.

Serve the form locally

The static page proves the form layout. A local HTTP server gives the page a route for loading and another route for submissions.

  • In the Visual Studio Code file sidebar, create server.mjs inside nextwork-help-bridge.
  • Paste this server foundation into server.mjs:
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const PORT = 3000;
const indexPath = new URL("./index.html", import.meta.url);

function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, {
    "Content-Type": "application/json; charset=utf-8",
  });
  response.end(JSON.stringify(payload));
}

What does the server foundation do?

  • The imports provide the server and file-reading features built into Node.js.
  • The PORT value places the local server on port 3000.
  • The indexPath value points to index.html beside the server file.
  • The sendJson() helper attaches the response status.
  • The helper returns a JSON payload to the browser.
  • Save server.mjs.
  • Confirm that server.mjs appears beside index.html in the Visual Studio Code file sidebar.

Server file in the wrong place?

Both files must sit directly inside nextwork-help-bridge. Move server.mjs out of any nested folder before continuing.

Help me check the file locations in my local help bridge.

  • In server.mjs, place this code directly after the closing brace of sendJson():
const server = createServer(async (request, response) => {
  if (request.method === "GET" && request.url === "/") {
    const html = await readFile(indexPath, "utf8");
    response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    response.end(html);
    return;
  }

  if (request.method === "POST" && request.url === "/api/help") {
    sendJson(response, 501, {
      error: "Community connection not wired yet.",
    });
    return;
  }

  sendJson(response, 404, { error: "Route not found." });
});

server.listen(PORT, "127.0.0.1");

How do the routes work?

  • The GET / route reads index.html for the browser.
  • The POST /api/help route returns the deliberate disconnected response.
  • The final response handles any route that the prototype does not recognize.
  • The server.listen() call limits the prototype to the local computer.
  • Save server.mjs.

Before you start the server, what do you expect the browser to receive from the root URL?

  • Start the local server from the existing nextwork-help-bridge terminal by running:
node server.mjs

What does this command do?

Node.js loads server.mjs as an ECMAScript module. The listening process keeps this terminal occupied while the browser sends requests.

  • In your browser's address bar, enter http://localhost:3000.

You should see the same five-field help form at the local server address. That is the first working slice: your form now comes from the local Node.js server.

Local form not loading?

Confirm that the terminal process is still running. Check that index.html sits beside server.mjs.

Compare the quotation marks and braces around both route conditions. Help me debug why my local Node.js help form is not loading.

✔️ Awesome, I've got everything!

Great. Your local server is running and serving the help form.

ⓧ I'd like to double check the full code

  • Compare your complete server.mjs file with this reference:
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const PORT = 3000;
const indexPath = new URL("./index.html", import.meta.url);

function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, {
    "Content-Type": "application/json; charset=utf-8",
  });
  response.end(JSON.stringify(payload));
}

const server = createServer(async (request, response) => {
  if (request.method === "GET" && request.url === "/") {
    const html = await readFile(indexPath, "utf8");
    response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    response.end(html);
    return;
  }

  if (request.method === "POST" && request.url === "/api/help") {
    sendJson(response, 501, {
      error: "Community connection not wired yet.",
    });
    return;
  }

  sendJson(response, 404, { error: "Route not found." });
});

server.listen(PORT, "127.0.0.1");

What should match?

This reference contains the file-serving route and the deliberate disconnected route. It also starts the server on the local address.

Connect the form submission

The page now loads from the server. Its button needs browser logic that packages every field for the POST /api/help route.

The browser uses fetch to send the request. The server deliberately responds with the missing-connection message.

  • In index.html, find the closing </main> line.
  • Paste this script directly below </main>:
    <script>
      const form = document.querySelector("#help-form");
      const status = document.querySelector("#status");
      const button = document.querySelector("#submit-button");
      const forumLink = document.querySelector("#forum-link");
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        const response = await fetch("/api/help", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(form).entries())) });
        const result = await response.json();
        status.textContent = result.error || result.message;
        forumLink.hidden = true;
        button.textContent = "Ask the community";
      });
    </script>

What does the submission script do?

  • The four element references connect the script to the form controls.
  • The submit listener prevents the browser's default page navigation.
  • The form values become a request payload for /api/help.
  • The returned message appears inside the status element.
  • The forum link stays hidden during the disconnected test.
  • Save index.html.
  • Refresh the browser page at http://localhost:3000.
  • Enter Build a NextWork Community Help Bridge in the Project field.
  • Enter Build the Help Form in the Step field.
  • Enter The request stops after I submit the form. in the What happened field.
  • Enter A new support post should appear for the community. in the What did you expect field.
  • Enter I refreshed the page and checked every field. in the What have you tried field.

Before you submit, do you think the request can reach the community with the current server route?

  • Click Ask the community.

You should see Community connection not wired yet. below the form. No Discord post is created.

That shortfall is intentional. It pinpoints the missing connection that the secure relay must provide.

Disconnected message not appearing?

Confirm that the script sits between </main> and </body>. Check that the server process is still running.

Make sure every sample field contains text before submitting. Help me debug why the deliberate disconnected response is not appearing.

✔️ Awesome, I've got everything!

Great. Save index.html with the complete submission script.

ⓧ I'd like to double check the full code

  • Compare your complete index.html file with this reference:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>NextWork Community Help</title>
  </head>
  <body>
    <main>
      <h1>Ask the NextWork community</h1>
      <p>Do not include passwords, API keys, private repository links, personal data, or customer information. Your request will be posted to Discord.</p>
      <form id="help-form">
        <label>Project <input name="project" maxlength="80" required /></label>
        <label>Step <input name="step" maxlength="80" required /></label>
        <label>What happened? <textarea name="problem" maxlength="400" required></textarea></label>
        <label>What did you expect? <textarea name="expected" maxlength="400" required></textarea></label>
        <label>What have you tried? <textarea name="tried" maxlength="400" required></textarea></label>
        <button id="submit-button" type="submit">Ask the community</button>
      </form>
      <p id="status" role="status" aria-live="polite"></p>
      <a id="forum-link" href="#" target="_blank" rel="noopener noreferrer" hidden>Open the Discord help forum</a>
    </main>
    <script>
      const form = document.querySelector("#help-form");
      const status = document.querySelector("#status");
      const button = document.querySelector("#submit-button");
      const forumLink = document.querySelector("#forum-link");
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        const response = await fetch("/api/help", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(form).entries())) });
        const result = await response.json();
        status.textContent = result.error || result.message;
        forumLink.hidden = true;
        button.textContent = "Ask the community";
      });
    </script>
  </body>
</html>

What should match?

This reference combines the five-field form with its browser-side submission logic. The status element displays the server response after every submission.

Your learner experience is visible and the missing connection is proven. Next, you'll prepare the forum destination that receives each support request.

Prepare the Discord Help Forum

Your help form now captures a learner's blocker. The request still ends at the deliberate connection error.

A persistent, topic-based destination gives each request its own discussion.

In this step, you will prepare a Discord Forum channel. You will create an Incoming Webhook for the secure relay.

In this step, get ready to:
  • Enable Discord Community if the server does not already use it.
  • Create a List View Forum channel named nextwork-help-lab.
  • Create an Incoming Webhook for the Forum channel.
Enable Community if needed

Discord Forum channels require Community to be enabled on the server. Community setup adds the server safeguards that Forum channels rely on.

  • Open your Discord server in a browser.
  • Sign in with your administrator account if Discord prompts you.

You'll see the server's channel list once your administrator account is active.

  • Select the server name at the top of the channel sidebar.
  • Select Server Settings.

The settings page shows whether Community is already active or still needs to be enabled.

✔️ Community is already enabled

Community settings are already available. Your server is ready for a Forum channel.

ⓧ Community is not enabled

  • Select Enable Community.
  • Select Get Started.

The guided setup now checks the safeguards required for a Community server.

  • Complete each safety requirement shown by Discord.
  • Select a Rules or Guidelines Channel when Discord asks for one.
  • Select a Community Updates or Moderators Channel when Discord asks for one.
  • Confirm your agreement with Discord's Community Guidelines.
  • Select Finish Setup.

That foundation is complete. Your server can now host Forum channels for structured support.

Community Controls Missing?

Confirm that your Discord account has administrator access to this server. Server-level Community settings may be unavailable when your role lacks permission.

Ask for help if the controls are still missing: help me check why I cannot access Community settings in my Discord server.

Create the help Forum channel

A Forum channel gives each support request a dedicated post. List View keeps those requests easy to scan as separate topics.

  • Return to your server's channel sidebar.
  • Hover over the channel category where the help forum should live.
  • Select the plus sign beside that category.
  • Select Forum as the channel type.
  • Enter nextwork-help-lab as the channel name.
  • Select List View as the layout.
  • Leave tags optional for the initial webhook test.
  • Select Create Channel.

Good progress. You'll see nextwork-help-lab in the channel sidebar with its posts arranged in List View.

  • Select nextwork-help-lab in the channel sidebar.
  • Record the forum page URL from your browser's address bar here: your Discord forum URL.

Forum Option Missing?

Confirm that Community is enabled for this server. Check that your administrator role can create channels.

Ask for help with the server state you can see: help me find the missing Forum channel option in Discord.

Create the Incoming Webhook

The webhook gives the server-side relay a one-way route into the help forum. The learner's browser never receives that posting credential.

Why Use an Incoming Webhook?

An Incoming Webhook is an HTTP endpoint tied to a specific Discord channel. Posting a payload makes a message appear in that destination.

This one-way pattern fits the help bridge because it requires no bot process. It also avoids a persistent connection.

  • Return to Server Settings.
  • Select Integrations.
  • Select Create Webhook.

You'll see the webhook editor with controls for its name and destination channel.

  • Enter NextWork Help Bridge as the webhook name.
  • Set the webhook channel to nextwork-help-lab.

Protect the Webhook URL

The copied URL can publish to your forum. Treat it like a password.

Keep it out of index.html and browser JavaScript.

Keep it out of screenshots.

Keep it out of source control.

  • Select Copy Webhook URL.
  • Keep the copied URL in your clipboard for the next step.

The private posting credential is ready. Your browser code remains free of the webhook secret.

Before you check, picture the channel name you expect the webhook settings to show.

  • Return to Server Settings.
  • Select Integrations.
  • Select NextWork Help Bridge.

You'll see the webhook targeting nextwork-help-lab. The page also displays the Copy Webhook URL action.

Webhook Controls Missing?

Confirm that you are viewing the server's Integrations settings. Check that your administrator role can manage webhooks.

Ask for help without sharing the webhook URL: help me find or configure the webhook controls in Discord.

Your Discord destination is ready for real requests. Next, you will connect the server-side relay while keeping the webhook URL away from the browser.

Connect the Secure Webhook Relay

Your help form now has a dedicated destination in Discord. However, every submission still ends at the deliberate connection error from the previous step.

In this step, you will replace that dead end with a server-side relay. The relay validates each request, protects the webhook URL, creates a structured Forum post, and returns a route into the community.

In this step, get ready to:
  • Store the webhook URL in ignored server-only configuration.
  • Connect the help form to the Discord Forum with a validated relay.
  • Submit a blocker and confirm the complete community handoff.
Protect the webhook configuration

An Incoming Webhook URL can create posts in its target channel. Keeping that URL in an ignored local file prevents browser code or source control from exposing it.

  • Create a file named .gitignore inside the open nextwork-help-bridge workspace.
  • Add this ignored filename to .gitignore:
config.json

What does this file protect?

The .gitignore entry tells source control to exclude the private config.json file. Your safe example file remains available to document the required configuration.

  • Create config.example.json beside server.mjs.
  • Paste this safe configuration template into config.example.json:
{
  "webhookUrl": "your-discord-webhook-url-here",
  "forumUrl": "your-discord-forum-url-here"
}

Why keep an example file?

The example documents the two required JSON keys without containing a working credential. Anyone using the project can see its configuration shape without gaining access to the webhook.

  • Save .gitignore.
  • Save config.example.json.
  • Confirm both filenames appear in the Visual Studio Code Explorer sidebar.

Missing a configuration file?

Check that both files sit beside index.html and server.mjs inside nextwork-help-bridge. Make sure the leading full stop in .gitignore is present.

Ask for help if the files are missing from the Explorer sidebar: help me check where my configuration files were created

The next file contains the live webhook URL. Treat it like a password. It stays on your Mac, outside browser code, screenshots, and source control.

  • Create config.json beside config.example.json.
  • Paste this private configuration structure into config.json:
{
  "webhookUrl": "https://discord.com/api/webhooks/REPLACE_WITH_PRIVATE_WEBHOOK_URL",
  "forumUrl": "https://discord.com/channels/REPLACE_WITH_SERVER_ID/REPLACE_WITH_FORUM_CHANNEL_ID"
}

What belongs in this file?

The server reads webhookUrl when it sends a support request. The browser receives forumUrl only after Discord accepts that request.

The values shown here are safe placeholders. Your local copy needs the real private webhook URL and the browser URL for nextwork-help-lab.

  • Replace the webhook placeholder with the private URL you copied earlier.
  • Replace the forum placeholder with the browser URL for nextwork-help-lab.
  • Save config.json.
  • Confirm .gitignore still contains config.json.
Wire the relay and browser response

The relay receives an HTTP POST request from the form. It cleans each field before turning the learner's context into a new Forum thread.

  • In server.mjs, replace the existing port and path declarations with this configuration-aware version:
const PORT = 3000;
const indexPath = new URL("./index.html", import.meta.url);
const configPath = new URL("./config.json", import.meta.url);

What changed here?

The new configPath points to the private configuration beside server.mjs. Building the path from import.meta.url keeps file loading anchored to the module.

  • Paste readJsonBody() below sendJson():
async function readJsonBody(request) {
  request.setEncoding("utf8");
  let body = "";

  for await (const chunk of request) {
    body += chunk;
    if (body.length > 10000) {
      throw new Error("Request too large.");
    }
  }

  return JSON.parse(body || "{}");
}

How is the request body handled?

The request arrives as a stream of text chunks. readJsonBody() combines those chunks before parsing the submitted JSON.

The size check stops an unexpectedly large body before it consumes more memory. This keeps the local relay focused on short support requests.

  • Save server.mjs.
  • Check that the new path and request reader compile by running:
node server.mjs

What does this check prove?

The command starts the local server with the new configuration path and request reader loaded. A successful start means JavaScript parsed both additions without a syntax error.

  • Return to http://localhost:3000 in your browser.
  • Refresh the page.

You should see the existing help form load. This confirms the first relay utilities compile without breaking the page.

  • Stop the server process from the terminal that is running it.

Server stopped with an error?

Check that configPath ends with ./config.json. Confirm every brace in readJsonBody() matches the code above.

Use this prompt for focused help: help me debug the new request body reader

  • Paste clean() below readJsonBody():
function clean(value, maxLength) {
  return String(value ?? "")
    .replaceAll("\r", "")
    .trim()
    .slice(0, maxLength);
}

Why clean every field?

The clean() helper converts missing values into empty strings. It also removes carriage returns, trims outer whitespace, and enforces the field's maximum length.

  • Replace the existing GET / branch with this error-handled version:
  if (request.method === "GET" && request.url === "/") {
    try {
      const html = await readFile(indexPath, "utf8");
      response.writeHead(200, {
        "Content-Type": "text/html; charset=utf-8",
      });
      response.end(html);
    } catch (error) {
      console.error(error);
      response.statusCode = 500;
      response.end("Could not load the help form.");
    }
    return;
  }

Why protect the page route?

The try block serves index.html normally. The catch block returns a controlled response if the file cannot be read.

  • Replace the deliberate POST /api/help failure with the next four code chunks in order.
  • Start the replacement with request parsing and required-field validation:
  if (request.method === "POST" && request.url === "/api/help") {
    try {
      const input = await readJsonBody(request);
      const project = clean(input.project, 80);
      const step = clean(input.step, 80);
      const problem = clean(input.problem, 400);
      const expected = clean(input.expected, 400);
      const tried = clean(input.tried, 400);

      if (!project || !step || !problem || !expected || !tried) {
        sendJson(response, 400, {
          error: "Complete every field before asking the community.",
        });
        return;
      }

What is validated first?

Each submitted value passes through clean() with the same length limit used by the form. The relay returns a validation error when any required field becomes empty.

  • Continue the same route with private configuration and structured post content:
      const config = JSON.parse(await readFile(configPath, "utf8"));
      const webhookUrl = String(config.webhookUrl ?? "").trim();
      const forumUrl = String(config.forumUrl ?? "").trim();

      if (!webhookUrl.startsWith("https://") || webhookUrl.includes("your-discord-webhook-url-here")) {
        sendJson(response, 500, {
          error: "Add a valid Discord webhook URL to config.json.",
        });
        return;
      }

      const threadName = `${project} | ${step}`.replace(/\s+/g, " ").slice(0, 90);
      const content = [
        `Project: ${project}`,
        `Step: ${step}`,
        "",
        "What happened:",
        problem,
        "",
        "What I expected:",
        expected,
        "",
        "What I tried:",
        tried,
      ].join("\n");

How is the Forum post structured?

The relay reads both URLs from the server-only file. It rejects a missing or placeholder webhook URL before contacting Discord.

The thread name combines the submitted project and step. The message body keeps the problem, expected result, and attempted fixes in separate sections.

  • Continue the route with the webhook request and response check:
      const discordResponse = await fetch(`${webhookUrl}?wait=true`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          username: "NextWork Help Bridge",
          thread_name: threadName,
          content,
          allowed_mentions: { parse: [] },
        }),
      });

      if (!discordResponse.ok) {
        console.error(`Discord returned ${discordResponse.status}.`);
        sendJson(response, 502, { error: "Discord did not accept the support request." });
        return;
      }

What does the webhook request do?

The ?wait=true query asks Discord to wait for confirmation. The thread_name field creates a dedicated post in the Forum channel.

The allowed_mentions object uses an empty parse array. Learner-generated mention text stays visible without notifying users or roles.

  • Finish the route with the success response and fallback error handling:
      sendJson(response, 201, {
        message: "Your request was posted to the NextWork community.",
        forumUrl: forumUrl.startsWith("https://") ? forumUrl : "",
      });
    } catch (error) {
      console.error(error);
      sendJson(response, 500, { error: "The bridge could not post your request." });
    }
    return;
  }

How does the browser learn about success?

A successful webhook call returns status 201 with a confirmation message. A valid forumUrl gives the browser a safe destination after the post exists.

The fallback response hides internal details from the learner. The full error remains in the server terminal for debugging.

  • Replace the final route fallback and listener with this version:
  sendJson(response, 404, { error: "Route not found." });
});

server.listen(PORT, "127.0.0.1", () => {
  console.log(`NextWork Help Bridge running at http://localhost:${PORT}`);
});

What changed at startup?

The listener now prints the local URL when the server starts. That message gives you a visible confirmation before you test the browser.

  • Save server.mjs.
  • Start the completed relay by running:
node server.mjs

What should the terminal confirm?

The server prints NextWork Help Bridge running at http://localhost:3000. This confirms the completed relay loaded its configuration and started listening.

Relay will not start?

A JSON parsing failure usually means config.json has a missing quote or comma. A file error usually means config.json is outside the nextwork-help-bridge workspace.

Use this prompt without sharing the webhook URL: help me debug why my secure relay will not start

The server can now complete the handoff. The browser needs clearer progress, success, and error states so the learner can see what happened.

  • In index.html, replace the file with the next four code chunks in order.
  • Start with the document head and compact page styles:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>NextWork Community Help</title>
    <style>
      :root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #f4f7fb; color: #172033; }
      * { box-sizing: border-box; }
      body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 32px 16px; }
      .card { width: min(720px, 100%); padding: 32px; border: 1px solid #d8e0ee; border-radius: 20px; background: #fff; }
      form { display: grid; gap: 18px; }
      .row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
      label { display: grid; gap: 8px; font-weight: 700; }
      input, textarea { width: 100%; border: 1px solid #bdc8da; border-radius: 10px; padding: 12px 14px; font: inherit; }
      textarea { min-height: 104px; resize: vertical; }
      button, .forum-link { min-height: 48px; border-radius: 10px; font: inherit; font-weight: 800; }
      button { border: 0; background: #4b63d2; color: #fff; cursor: pointer; }
      .status.error { color: #a12828; } .status.success { color: #17663a; } [hidden] { display: none; }
      @media (max-width: 620px) { .row { grid-template-columns: 1fr; } }
    </style>
  </head>

What do these styles change?

The layout centers a bordered card with readable spacing and form controls. The media query collapses the project and step fields into one column on smaller screens.

Separate success and error colors make the submission result easy to scan. The hidden rule keeps the Forum link out of view until the server confirms success.

  • Continue with the polished form markup:
  <body>
    <main class="card">
      <p>Peer support bridge</p>
      <h1>Ask the NextWork community</h1>
      <p>Share the learning context your peers need, without rewriting your whole project story in Discord.</p>
      <p>Do not include passwords, API keys, private repository links, personal data, or customer information. Your request will be posted to Discord.</p>
      <form id="help-form">
        <div class="row">
          <label>Project <input name="project" maxlength="80" placeholder="Current NextWork project" required /></label>
          <label>Step <input name="step" maxlength="80" placeholder="The step where you are blocked" required /></label>
        </div>
        <label>What happened?<textarea name="problem" maxlength="400" required></textarea></label>
        <label>What did you expect?<textarea name="expected" maxlength="400" required></textarea></label>
        <label>What have you tried?<textarea name="tried" maxlength="400" required></textarea></label>
        <button id="submit-button" type="submit">Ask the community</button>
      </form>
      <p id="status" class="status" role="status" aria-live="polite"></p>
      <a id="forum-link" class="forum-link" href="#" target="_blank" rel="noopener noreferrer" hidden>Open the Discord help forum</a>
    </main>

What improved in the form?

The form keeps the five context fields from the disconnected prototype. New placeholders clarify the project and step inputs.

The status area and Forum link now have styling hooks. Their existing IDs still connect them to the submission script.

  • Continue with the submission setup and pending state:
    <script>
      const form = document.querySelector("#help-form");
      const status = document.querySelector("#status");
      const button = document.querySelector("#submit-button");
      const forumLink = document.querySelector("#forum-link");
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        button.disabled = true;
        button.textContent = "Posting request...";
        status.className = "status";
        status.textContent = "Sending your context to the community...";
        forumLink.hidden = true;
        const payload = Object.fromEntries(new FormData(form).entries());

What happens during submission?

The handler disables the button while the request is in flight. It also displays a progress message and hides any link left from an earlier success.

The payload object collects the five named form fields. That object becomes the JSON body sent to the relay.

  • Finish the script with the relay request and visible result states:
        try {
          const response = await fetch("/api/help", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
          const result = await response.json();
          if (!response.ok) throw new Error(result.error || "The request could not be posted.");
          status.className = "status success";
          status.textContent = result.message;
          form.reset();
          if (result.forumUrl) { forumLink.href = result.forumUrl; forumLink.hidden = false; }
        } catch (error) {
          status.className = "status error";
          status.textContent = error.message;
        } finally {
          button.disabled = false;
          button.textContent = "Ask the community";
        }
      });
    </script>
  </body>
</html>

How does the result reach the learner?

A successful response displays the server message, clears the form, and reveals the Forum link. A failed response displays the relay's error in the same status area.

The finally block restores the submit button after either outcome. The form always returns to a usable state.

  • Save index.html.
  • Return to http://localhost:3000.
  • Refresh the browser page.

You should see a centered card with a purple submit button. The project and step fields sit side by side on a wide browser window.

Form still looks unstyled?

Confirm the style element is inside the document head. Check that the main element uses class="card".

Use this prompt for help with the visual update: help me find why the final help form styles are missing

✔️ Awesome, I've got everything!

Great. Save every file before testing the live Discord handoff.

ⓧ I'd like to double check the full code

Compare each file with the references below. Keep the real URLs in your private config.json when you finish comparing.

config.json

What should this file contain?

The file contains one ignored filename. This keeps the private local configuration outside source control.

{
  "webhookUrl": "your-discord-webhook-url-here",
  "forumUrl": "your-discord-forum-url-here"
}

What should the example contain?

The example contains safe placeholder values for both required keys. It must never contain the live webhook URL.

{
  "webhookUrl": "https://discord.com/api/webhooks/REPLACE_WITH_PRIVATE_WEBHOOK_URL",
  "forumUrl": "https://discord.com/channels/REPLACE_WITH_SERVER_ID/REPLACE_WITH_FORUM_CHANNEL_ID"
}

Preserve your private values

This reference shows the expected key structure with redacted URL segments. Keep your real webhook and Forum URLs in the ignored local file.

import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const PORT = 3000;
const indexPath = new URL("./index.html", import.meta.url);
const configPath = new URL("./config.json", import.meta.url);

function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, {
    "Content-Type": "application/json; charset=utf-8",
  });
  response.end(JSON.stringify(payload));
}

async function readJsonBody(request) {
  request.setEncoding("utf8");
  let body = "";

  for await (const chunk of request) {
    body += chunk;
    if (body.length > 10000) {
      throw new Error("Request too large.");
    }
  }

  return JSON.parse(body || "{}");
}

function clean(value, maxLength) {
  return String(value ?? "")
    .replaceAll("\r", "")
    .trim()
    .slice(0, maxLength);
}

const server = createServer(async (request, response) => {
  if (request.method === "GET" && request.url === "/") {
    try {
      const html = await readFile(indexPath, "utf8");
      response.writeHead(200, {
        "Content-Type": "text/html; charset=utf-8",
      });
      response.end(html);
    } catch (error) {
      console.error(error);
      response.statusCode = 500;
      response.end("Could not load the help form.");
    }
    return;
  }

  if (request.method === "POST" && request.url === "/api/help") {
    try {
      const input = await readJsonBody(request);
      const project = clean(input.project, 80);
      const step = clean(input.step, 80);
      const problem = clean(input.problem, 400);
      const expected = clean(input.expected, 400);
      const tried = clean(input.tried, 400);

      if (!project || !step || !problem || !expected || !tried) {
        sendJson(response, 400, {
          error: "Complete every field before asking the community.",
        });
        return;
      }

      const config = JSON.parse(await readFile(configPath, "utf8"));
      const webhookUrl = String(config.webhookUrl ?? "").trim();
      const forumUrl = String(config.forumUrl ?? "").trim();

      if (!webhookUrl.startsWith("https://") || webhookUrl.includes("your-discord-webhook-url-here")) {
        sendJson(response, 500, {
          error: "Add a valid Discord webhook URL to config.json.",
        });
        return;
      }

      const threadName = `${project} | ${step}`.replace(/\s+/g, " ").slice(0, 90);
      const content = [
        `Project: ${project}`,
        `Step: ${step}`,
        "",
        "What happened:",
        problem,
        "",
        "What I expected:",
        expected,
        "",
        "What I tried:",
        tried,
      ].join("\n");

      const discordResponse = await fetch(`${webhookUrl}?wait=true`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          username: "NextWork Help Bridge",
          thread_name: threadName,
          content,
          allowed_mentions: { parse: [] },
        }),
      });

      if (!discordResponse.ok) {
        console.error(`Discord returned ${discordResponse.status}.`);
        sendJson(response, 502, { error: "Discord did not accept the support request." });
        return;
      }

      sendJson(response, 201, {
        message: "Your request was posted to the NextWork community.",
        forumUrl: forumUrl.startsWith("https://") ? forumUrl : "",
      });
    } catch (error) {
      console.error(error);
      sendJson(response, 500, { error: "The bridge could not post your request." });
    }
    return;
  }

  sendJson(response, 404, { error: "Route not found." });
});

server.listen(PORT, "127.0.0.1", () => {
  console.log(`NextWork Help Bridge running at http://localhost:${PORT}`);
});

What should the server contain?

The complete server reads the private configuration, validates all five fields, posts a structured thread, suppresses mentions, and returns the Forum URL.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>NextWork Community Help</title>
    <style>
      :root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #f4f7fb; color: #172033; }
      * { box-sizing: border-box; }
      body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 32px 16px; }
      .card { width: min(720px, 100%); padding: 32px; border: 1px solid #d8e0ee; border-radius: 20px; background: #fff; }
      form { display: grid; gap: 18px; }
      .row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
      label { display: grid; gap: 8px; font-weight: 700; }
      input, textarea { width: 100%; border: 1px solid #bdc8da; border-radius: 10px; padding: 12px 14px; font: inherit; }
      textarea { min-height: 104px; resize: vertical; }
      button, .forum-link { min-height: 48px; border-radius: 10px; font: inherit; font-weight: 800; }
      button { border: 0; background: #4b63d2; color: #fff; cursor: pointer; }
      .status.error { color: #a12828; } .status.success { color: #17663a; } [hidden] { display: none; }
      @media (max-width: 620px) { .row { grid-template-columns: 1fr; } }
    </style>
  </head>
  <body>
    <main class="card">
      <p>Peer support bridge</p>
      <h1>Ask the NextWork community</h1>
      <p>Share the learning context your peers need, without rewriting your whole project story in Discord.</p>
      <p>Do not include passwords, API keys, private repository links, personal data, or customer information. Your request will be posted to Discord.</p>
      <form id="help-form">
        <div class="row">
          <label>Project <input name="project" maxlength="80" placeholder="Current NextWork project" required /></label>
          <label>Step <input name="step" maxlength="80" placeholder="The step where you are blocked" required /></label>
        </div>
        <label>What happened?<textarea name="problem" maxlength="400" required></textarea></label>
        <label>What did you expect?<textarea name="expected" maxlength="400" required></textarea></label>
        <label>What have you tried?<textarea name="tried" maxlength="400" required></textarea></label>
        <button id="submit-button" type="submit">Ask the community</button>
      </form>
      <p id="status" class="status" role="status" aria-live="polite"></p>
      <a id="forum-link" class="forum-link" href="#" target="_blank" rel="noopener noreferrer" hidden>Open the Discord help forum</a>
    </main>
    <script>
      const form = document.querySelector("#help-form");
      const status = document.querySelector("#status");
      const button = document.querySelector("#submit-button");
      const forumLink = document.querySelector("#forum-link");
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        button.disabled = true;
        button.textContent = "Posting request...";
        status.className = "status";
        status.textContent = "Sending your context to the community...";
        forumLink.hidden = true;
        const payload = Object.fromEntries(new FormData(form).entries());
        try {
          const response = await fetch("/api/help", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
          const result = await response.json();
          if (!response.ok) throw new Error(result.error || "The request could not be posted.");
          status.className = "status success";
          status.textContent = result.message;
          form.reset();
          if (result.forumUrl) { forumLink.href = result.forumUrl; forumLink.hidden = false; }
        } catch (error) {
          status.className = "status error";
          status.textContent = error.message;
        } finally {
          button.disabled = false;
          button.textContent = "Ask the community";
        }
      });
    </script>
  </body>
</html>

What should the page contain?

The complete page includes the polished form plus pending, success, and error behavior. It reveals the Forum link only after the relay returns a valid URL.

Complete the community handoff

The form and relay now meet at one endpoint. This final check proves that a learner action becomes a dedicated community discussion without exposing the webhook URL.

  • Return to the help form at http://localhost:3000.
  • Enter Build a NextWork Community Help Bridge in the Project field.
  • Enter Connect the Secure Webhook Relay in the Step field.
  • Describe a realistic blocker in the What happened? field.
  • Include @everyone in the blocker text to test mention suppression.
  • Describe the intended result in the What did you expect? field.
  • List one attempted fix in the What have you tried? field.

Before you submit, what do you expect to appear in the browser and in nextwork-help-lab?

  • Click Ask the community.

You should see Your request was posted to the NextWork community. in green. You should also see an Open the Discord help forum link below the form.

  • Click Open the Discord help forum.
  • Open the new post in nextwork-help-lab.
  • Confirm the post title contains the submitted project and step.
  • Confirm the post separates what happened, what you expected, and what you tried.
  • Confirm @everyone appears as plain text without creating a community notification.

You made the full bridge work. A learner can now submit context from the local form and land in an organized Discord discussion with mentions safely suppressed.

Support request did not appear?

If the browser reports a configuration problem, check the two quoted URLs in config.json without sharing them. If Discord rejects the request, confirm the NextWork Help Bridge webhook still targets nextwork-help-lab.

If the browser succeeds without showing the Forum link, confirm forumUrl contains the browser URL for the Forum channel. Restart the server after saving configuration changes.

Use this prompt without including credentials: help me trace a failed webhook handoff

Secret mission

Add a Peer-Support Triage Loop

The bridge already turns learner context into a Discord Forum post. This challenge adds guidelines plus visible triage tags. You will move one request from Needs help to Resolved after a peer reply.

Clean Up Your Resources

Clean Up Your Resources

Your prototype runs on your Mac. It uses your existing Discord server at no incremental project cost.

The Forum channel and Incoming Webhook remain available until you remove them. Choose whether to keep the bridge ready, pause local submissions, or remove every prototype resource.

Resources you used:

  • The local nextwork-help-bridge workspace containing config.json, config.example.json, .gitignore, index.html, and server.mjs.
  • The NextWork Help Bridge webhook targeting nextwork-help-lab.
  • The nextwork-help-lab Forum channel containing create-post guidelines, support tags, and the resolved triage request.

Keep everything running

No resource deletion is needed. Choose this if your team plans to continue testing the bridge or using the lab forum.

  • Return to the Terminal window you used for server.mjs.
  • Press Control+C when you finish testing.
  • Keep config.json inside the ignored nextwork-help-bridge workspace.
  • Keep the private webhook URL out of screenshots and source control.

The forum remains ready for new support threads. The webhook can resume posting when you restart the local bridge.

Pause - I'll come back to this later

Pause the local Node.js server while preserving the bridge files. The Discord forum remains available for existing support discussions.

  • Return to the Terminal window you used for server.mjs.
  • Press Control+C to stop the local server.
  • Leave the workspace, webhook, Forum channel, and resolved triage request in place.

New form submissions stop while the local server is offline. Existing forum posts and triage tags remain available.

Delete - I don't want to use this again

Remove every prototype resource when your team is finished testing. Deleting the webhook invalidates its private URL.

This is the broadest cleanup in the project. The lab's guidelines, tags, and support threads go with the channel.

  • Return to Discord.
  • Open Server Settings for the server from earlier.
  • Select Integrations.
  • Select the NextWork Help Bridge webhook.
  • Use the webhook deletion control to delete it.
  • Confirm the webhook is no longer listed under Integrations.

The URL stored in config.json can no longer post through the deleted webhook.

  • Return to nextwork-help-lab in Discord.
  • Open the channel's settings.
  • Use the channel deletion control to delete nextwork-help-lab.
  • Confirm nextwork-help-lab no longer appears in the server's channel list.

The local workspace is the final place that holds the private webhook URL. Removing it clears the configuration and every prototype file.

  • Return to the Terminal window from earlier.
  • Press Control+C if the local server is still running.
  • Confirm the Terminal prompt is inside the nextwork-help-bridge folder.
  • Delete the private configuration and local workspace by running these commands:
rm config.json
cd ..
rm -rf nextwork-help-bridge

What Does This Cleanup Do?

  • The first line removes config.json before the rest of the workspace.
  • The second line moves Terminal into the folder that contains nextwork-help-bridge.
  • The final line removes the remaining prototype files.

Terminal returns to the parent folder with no output when the cleanup succeeds. The private configuration and local project files are gone.

Workspace Still Present?

Confirm that the Terminal prompt was inside nextwork-help-bridge before you ran the cleanup commands.

Help me safely remove my local bridge workspace.

Nice Work!

Nice Work!

You did it! Your secure local help bridge now turns a learner's project blocker into an organized Discord discussion.

You've learned how to:

  • Built a learner-facing help form that captures the context peers need to reproduce a blocker.
  • Connected a server-side relay to the forum through a Discord Incoming Webhook. The relay validates every field. The private webhook URL stays inside ignored config.json. Mention suppression prevents unexpected pings.
  • Completed a community handoff where each request becomes a dedicated forum post. The learner receives a direct route into the support discussion.
  • Completed the optional Secret Mission by moving a real support request from Needs help to Resolved. A peer reply supplied the fix before you closed the request.

Ready to quiz yourself?