Build an Agent Governance Control Room

Build a local dashboard with policy gates, human review, and audit logs.

Introduction

30 Second Summary

When software can spend money on its own, one cheap choice may be harmless. One unchecked purchase can empty the till before anyone notices.

In this project, you will build a dark-mode safety control room for AI agent governance around a simulated lemonade seller. You will see every proposed purchase follow a visible safety path based on its cost.

What You'll Build

Press Start Simulation to watch a $4 lemon purchase pass automatically before a $60 robotic juicer stops the run for your decision.

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

  • A live control room where a simulated lemonade seller starts with $25 and works toward $100 in revenue. You can watch both totals change as decisions run.
  • Deterministic policy gates that auto-approve actions below $5 after two seconds. Purchases at $5 or above pause for human approval. Unanswered requests trigger a 10-second default-deny fallback.
  • Append-only audit evidence that survives browser refreshes through LocalStorage. A Slack Block Kit mock displays a local alert with a JSON payload preview. The masked webhook value is never transmitted or stored.
  • Secret Mission: Change the risk threshold to $10 and prove the new policy routing through the audit trail.

Are there any prerequisites?

Beginner HTML, CSS, and JavaScript knowledge is enough.

The project runs locally with your existing VS Code, Python 3, and Safari or Google Chrome setup without a Slack workspace.

Before We Start

This first checkpoint anchors the project in a clear responsibility. Your control room protects company cash and operational decisions by pausing high-cost agent actions for human oversight.

Serve the Control Room Locally

Your control room eventually stores audit evidence in LocalStorage. That evidence needs a consistent browser origin so it remains available after a refresh.

Direct file: pages can handle LocalStorage inconsistently across browsers. A localhost server gives index.html the stable origin this project needs.

Visual Studio Code keeps your project in one workspace. Python 3 provides the local server without another installation.

In this step, get ready to:
  • Prepare the local project structure in VS Code.
  • Serve the project with Python 3.
  • Confirm the page loads from localhost.
Create the local project file
  • Press Cmd+Space on macOS or the Windows key on Windows to open the search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open the editor.

The editor is now open. Next, give the project a fixed home on your Desktop.

  • Click File in the top menu bar.
  • Click Open Folder....
  • Select Desktop in the folder picker.
  • Click the new-folder button.
  • Enter agent-governance-control-room as the folder name.
  • Click Create.
  • Click Open.

The Explorer sidebar now shows the empty agent-governance-control-room folder.

A minimal HTML document gives the server a page to return. Its visible heading also makes the browser check unambiguous.

  • Click the new-file icon beside agent-governance-control-room in the Explorer sidebar.
  • Enter index.html as the file name.
  • Press Enter to create the file.
  • Add the initial control-room document to index.html by pasting this code:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Agent Governance Control Room</title>
</head>
<body>
          <h1>Agent Governance Control Room</h1>
</body>
</html>

What does this document do?

  • The <!doctype html> declaration tells the browser to use modern HTML behavior.
  • The character metadata prepares the page to display text consistently.
  • The viewport metadata prepares the page for different screen sizes.
  • The <title> element names the browser tab.
  • The <h1> element gives you a visible result when the server works.
  • Save index.html by pressing Cmd+S on macOS or Ctrl+S on Windows.

The Explorer sidebar now lists index.html inside agent-governance-control-room.

Can't See index.html?

  • Check that index.html sits directly inside agent-governance-control-room.
  • Check that the file name ends with .html.
  • Help me create the project folder and index.html file in VS Code.

✔️ Awesome, I've got everything!

Your initial index.html file is ready for the local server.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Agent Governance Control Room</title>
</head>
<body>
          <h1>Agent Governance Control Room</h1>
</body>
</html>
Start the local HTTP server

The integrated terminal runs commands from the folder currently open in VS Code. Starting there ensures the server exposes index.html instead of files from another location.

  • Click Terminal in the VS Code menu bar.
  • Click New Terminal.

The terminal prompt should end with agent-governance-control-room. This confirms commands run from the folder containing index.html.

  • Confirm the available Python 3 command by running:
python3 -V

What Does This Check?

The python3 command selects Python 3. The -V option displays its installed version.

You'll see a Python version line in the terminal. This confirms the local server command is available.

An HTTP server turns the current folder into a browser origin on port 8000. The server keeps this terminal occupied while it waits for browser requests.

  • Start the local server from agent-governance-control-room by running this command:
python3 -m http.server

What Does This Command Do?

The command starts Python's built-in local server from the current folder. It serves that folder on port 8000.

Keeping the process active allows your browser to request index.html from a stable localhost origin.

You'll see the terminal indicate that Python is serving the folder on port 8000. The prompt stays occupied while the server runs.

  • Leave this terminal running while you build the project.

Server Not Starting?

  • Check that the terminal prompt ends with agent-governance-control-room.
  • Stop any other local server already using port 8000 before retrying.
  • Help me troubleshoot the local Python server.
Load the page from localhost

Safari or Google Chrome can now request the file through the running server. A successful page load proves that the folder and server are connected.

Before you load the page, what heading do you expect the browser to display?

  • Press Cmd+Space on macOS or the Windows key on Windows to open the search bar.
  • Type Safari or Google Chrome into the search bar.
  • Press Enter to open the browser.
  • Press Cmd+L on macOS or Ctrl+L on Windows to focus the address bar.
  • Enter http://localhost:8000 in the address bar.
  • Press Enter to load the page.

You'll see Agent Governance Control Room as the page heading. That's the foundation in place: your project now loads from the stable origin needed for persistent audit evidence.

Page Not Loading?

  • Check that the VS Code terminal still shows the running server process.
  • Confirm the browser address is exactly http://localhost:8000.
  • Help me troubleshoot the localhost page.

Your stable localhost foundation is ready. Next up, an ungoverned run will expose the spending problem inside your new dashboard shell.

Expose the Ungoverned Overspend

Your control room has a stable local address. You can now test what happens when a spending agent operates without oversight.

A dashboard can look controlled while its execution path applies every proposal. This step exposes that missing deterministic policy gate before any safeguard is added.

In this step, get ready to:
  • Build the dark control-room dashboard shell.
  • Add a temporary runner that executes the synthetic action catalog without a risk check.
  • Inspect how the first two proposals affect the $25 balance.
Build the dashboard shell

The dashboard shell gives the agent's decisions a visible home. HTML creates the panels that hold each part of the simulation.

CSS separates the financial summary from the decision workspace. It also gives unsafe balances a clear warning colour.

  • In VS Code from earlier, select index.html in the Explorer sidebar.
  • Replace the contents of index.html by pasting this dashboard foundation:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Agent Governance Control Room</title>
  <style>
    /* Create the dark control-room layout used by every panel. */
    :root { color-scheme: dark; --bg: #07111f; --panel: #0d1b2d; --line: #243955; --text: #e7eef8; --muted: #91a4bd; --blue: #4da3ff; --red: #fb7185; }
    * { box-sizing: border-box; }
    body { margin: 0; min-height: 100vh; font-family: Inter, system-ui, sans-serif; background: var(--bg); color: var(--text); }
    button { padding: 12px 16px; border: 0; border-radius: 10px; background: var(--blue); font-weight: 800; cursor: pointer; } button:disabled { opacity: 0.5; }
    .app-shell { width: min(1200px, 100%); margin: 0 auto; padding: 24px; }
    .topbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
    .summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 22px 0 14px; }
    .workspace { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
    .card { padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); }
    .stat-label { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; } .stat-value { margin-top: 8px; font-size: 1.9rem; font-weight: 900; }
    .danger { color: var(--red); }
    .proposal-body { min-height: 150px; }
    .badge { display: inline-block; padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); }
    .audit-list { display: grid; gap: 8px; } .audit-row { padding: 10px; border-bottom: 1px solid var(--line); }
    @media (max-width: 760px) { .summary-grid, .workspace { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <div class="app-shell">
    <header class="topbar"><div><p>LEMONADE OPERATIONS</p><h1>Agent Governance Control Room</h1></div><button id="startButton" type="button">Start Simulation</button></header>
    <section class="summary-grid"><article class="card"><div class="stat-label">Available Cash</div><div id="cashValue" class="stat-value">$25</div></article><article class="card"><div class="stat-label">Revenue</div><div class="stat-value">$0</div></article><article class="card"><div class="stat-label">Revenue Target</div><div class="stat-value">$100</div></article><article class="card"><div class="stat-label">High-Risk Threshold</div><div class="stat-value">$5</div></article></section>
  </div>
</body></html>

What does this shell add?

  • The colour variables keep the dark palette consistent across the page.
  • The summary grid places cash beside revenue information and the future policy threshold.
  • The danger class provides a visible warning state for a negative balance.
  • Save index.html.
  • Switch back to the browser from earlier.
  • Refresh http://localhost:8000.

You'll see a dark page with the control-room title. Four summary cards show $25 cash alongside the revenue target and risk threshold.

Still seeing the initial page?

Confirm that you edited the index.html file inside agent-governance-control-room. Check that the local server from the previous step is still running.

Make sure you saved the file before refreshing the browser.

Help me find why my updated dashboard is not appearing.

Add the control-room panels

The summary cards show the business state. The workspace now needs separate areas for proposals, policy context, notifications, and decision evidence.

A temporary audit log makes each applied cost visible. This gives you a concrete record of how the naive runner behaves.

  • In index.html, find the closing </section> tag for the summary grid.
  • Paste this workspace immediately below that tag before the closing </div> tag:
    <!-- Keep live proposals and policy context beside the evidence column. -->
    <main class="workspace">
      <div>
        <section class="card">
          <h2>Current Proposal</h2>
          <div class="proposal-body"><h3 id="actionName">Waiting to start</h3><p id="decisionMessage">No action has run yet.</p><span class="badge">Risk: Not assessed</span></div>
        </section>
        <section class="card">
          <h2>Policy</h2>
          <p>Low risk: under $5. High risk: $5 and up.</p>
        </section>
        <section class="card">
          <h2>Notifications</h2>
          <p>Human review alerts will appear here.</p>
        </section>
      </div>
      <section class="card">
        <h2>Audit Log</h2>
        <div id="auditList" class="audit-list"><p>No decisions recorded yet.</p></div>
      </section>
    </main>

How is the workspace organized?

  • The proposal area shows the action currently being processed.
  • The policy area states the intended $5 boundary without enforcing it yet.
  • The notification area reserves space for future human review alerts.
  • The audit area records the costs that the temporary runner applies.
  • Save index.html.
  • Switch back to the browser from earlier.
  • Refresh the page.

You'll see a two-column workspace below the summary cards. The proposal, policy, notification, and audit areas are all visible.

Are the new panels missing?

Check that the new <main> element sits inside app-shell. It must appear before that container's closing tag.

Check that every opening <section> tag has a matching closing tag.

Help me fix the missing dashboard panels.

Run the ungoverned simulation

The interface can now display an agent run. A short JavaScript catalog supplies the first two lemonade proposals.

The temporary runner processes the catalog in sequence. Each result updates the cash card and adds a visible audit row.

  • In index.html, find the closing </body> tag.
  • Paste this script directly above that tag:
<script>
  "use strict";
  // Keep the starting balance and proposal timing together.
  const CONFIG = Object.freeze({ initialCash: 25, betweenActionsMs: 2000 });
  const ACTIONS = Object.freeze([
    Object.freeze({ id: "buy-lemons", name: "Buy Lemons", cost: 4 }),
    Object.freeze({ id: "robotic-juicer", name: "Purchase Robotic Juicer", cost: 60 })
  ]);
  // Cache the controls that change during the simulation.
  const elements = Object.freeze({ startButton: document.querySelector("#startButton"), cashValue: document.querySelector("#cashValue"), actionName: document.querySelector("#actionName"), decisionMessage: document.querySelector("#decisionMessage"), auditList: document.querySelector("#auditList") });
  // Apply every proposal without checking its risk or available cash.
  elements.startButton.addEventListener("click", () => {
    let cash = CONFIG.initialCash;
    elements.auditList.replaceChildren();
    elements.startButton.disabled = true;
    ACTIONS.forEach((action, index) => {
      window.setTimeout(() => {
        cash -= action.cost;
        elements.cashValue.textContent = `$${cash}`;
        elements.cashValue.classList.toggle("danger", cash < 0);
        elements.actionName.textContent = action.name;
        elements.decisionMessage.textContent = `${action.name} applied with no risk check.`;
        const row = document.createElement("div");
        row.className = "audit-row";
        row.textContent = `${action.name} | Cost: $${action.cost} | Cash: $${cash}`;
        elements.auditList.append(row);
        if (index === ACTIONS.length - 1) elements.startButton.disabled = false;
      }, index * CONFIG.betweenActionsMs); });
  });
</script>

What does this runner do?

  • CONFIG stores the $25 starting balance and the delay between proposals.
  • ACTIONS contains Buy Lemons at $4 and Purchase Robotic Juicer at $60.
  • The click handler processes each action in order and updates the visible balance.
  • Each processed proposal creates a row that preserves the action's immediate financial effect.
  • Save index.html.

Before you click Start Simulation, do you expect the $25 balance to remain above zero after both costs are applied?

  • Switch back to the browser from earlier.
  • Refresh the page.
  • Click Start Simulation.

You'll first see Buy Lemons reduce the balance to $21. Two seconds later, Purchase Robotic Juicer pushes it to -$39.

You found the failure on purpose. The red balance proves that the runner can spend beyond its financial authority.

This failure is intentional

The runner applies both costs without consulting the visible $5 policy. The dashboard now provides evidence that a displayed rule has no value until the execution path enforces it.

Does the button leave the balance unchanged?

Confirm that the script appears above the closing </body> tag. Check that startButton and cashValue match the IDs in your HTML.

Refresh the page after saving so the browser loads the new script.

Help me debug the ungoverned simulation button.

✔️ Awesome, I've got everything!

Your dashboard shows a negative balance after both proposals. Make sure index.html is saved.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Agent Governance Control Room</title>
  <style>
    /* Create the dark control-room layout used by every panel. */
    :root { color-scheme: dark; --bg: #07111f; --panel: #0d1b2d; --line: #243955; --text: #e7eef8; --muted: #91a4bd; --blue: #4da3ff; --red: #fb7185; }
    * { box-sizing: border-box; }
    body { margin: 0; min-height: 100vh; font-family: Inter, system-ui, sans-serif; background: var(--bg); color: var(--text); }
    button { padding: 12px 16px; border: 0; border-radius: 10px; background: var(--blue); font-weight: 800; cursor: pointer; } button:disabled { opacity: 0.5; }
    .app-shell { width: min(1200px, 100%); margin: 0 auto; padding: 24px; }
    .topbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
    .summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 22px 0 14px; }
    .workspace { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
    .card { padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); }
    .stat-label { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; } .stat-value { margin-top: 8px; font-size: 1.9rem; font-weight: 900; }
    .danger { color: var(--red); }
    .proposal-body { min-height: 150px; }
    .badge { display: inline-block; padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); }
    .audit-list { display: grid; gap: 8px; } .audit-row { padding: 10px; border-bottom: 1px solid var(--line); }
    @media (max-width: 760px) { .summary-grid, .workspace { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <div class="app-shell">
    <header class="topbar"><div><p>LEMONADE OPERATIONS</p><h1>Agent Governance Control Room</h1></div><button id="startButton" type="button">Start Simulation</button></header>
    <section class="summary-grid"><article class="card"><div class="stat-label">Available Cash</div><div id="cashValue" class="stat-value">$25</div></article><article class="card"><div class="stat-label">Revenue</div><div class="stat-value">$0</div></article><article class="card"><div class="stat-label">Revenue Target</div><div class="stat-value">$100</div></article><article class="card"><div class="stat-label">High-Risk Threshold</div><div class="stat-value">$5</div></article></section>
    <!-- Keep live proposals and policy context beside the evidence column. -->
    <main class="workspace">
      <div>
        <section class="card">
          <h2>Current Proposal</h2>
          <div class="proposal-body"><h3 id="actionName">Waiting to start</h3><p id="decisionMessage">No action has run yet.</p><span class="badge">Risk: Not assessed</span></div>
        </section>
        <section class="card">
          <h2>Policy</h2>
          <p>Low risk: under $5. High risk: $5 and up.</p>
        </section>
        <section class="card">
          <h2>Notifications</h2>
          <p>Human review alerts will appear here.</p>
        </section>
      </div>
      <section class="card">
        <h2>Audit Log</h2>
        <div id="auditList" class="audit-list"><p>No decisions recorded yet.</p></div>
      </section>
    </main>
  </div>
<script>
  "use strict";
  // Keep the starting balance and proposal timing together.
  const CONFIG = Object.freeze({ initialCash: 25, betweenActionsMs: 2000 });
  const ACTIONS = Object.freeze([
    Object.freeze({ id: "buy-lemons", name: "Buy Lemons", cost: 4 }),
    Object.freeze({ id: "robotic-juicer", name: "Purchase Robotic Juicer", cost: 60 })
  ]);
  // Cache the controls that change during the simulation.
  const elements = Object.freeze({ startButton: document.querySelector("#startButton"), cashValue: document.querySelector("#cashValue"), actionName: document.querySelector("#actionName"), decisionMessage: document.querySelector("#decisionMessage"), auditList: document.querySelector("#auditList") });
  // Apply every proposal without checking its risk or available cash.
  elements.startButton.addEventListener("click", () => {
    let cash = CONFIG.initialCash;
    elements.auditList.replaceChildren();
    elements.startButton.disabled = true;
    ACTIONS.forEach((action, index) => {
      window.setTimeout(() => {
        cash -= action.cost;
        elements.cashValue.textContent = `$${cash}`;
        elements.cashValue.classList.toggle("danger", cash < 0);
        elements.actionName.textContent = action.name;
        elements.decisionMessage.textContent = `${action.name} applied with no risk check.`;
        const row = document.createElement("div");
        row.className = "audit-row";
        row.textContent = `${action.name} | Cost: $${action.cost} | Cash: $${cash}`;
        elements.auditList.append(row);
        if (index === ACTIONS.length - 1) elements.startButton.disabled = false;
      }, index * CONFIG.betweenActionsMs); });
  });
</script>
</body></html>

Your dashboard now proves why visible policy text alone cannot govern an agent. Next up, you will turn that evidence into an enforced risk boundary with automatic low-risk routing and human review.

Gate Decisions by Risk

The robotic juicer pushed the agent below its available cash in the previous step. That failure proves the simulation needs a boundary before any proposal can change the business state.

A deterministic policy gate classifies each proposal from its cost. Routine purchases receive automated approval. Expensive purchases pause for human review.

In this step, get ready to:
  • Organize the governance data into modular JavaScript boundaries.
  • Classify each proposal against a $5 risk threshold.
  • Route low-risk actions to automation and high-risk actions to human review.
Centralize the policy data

The temporary runner spreads important values through the script. A central configuration object gives the policy one source of truth.

  • In the VS Code Explorer, switch back to index.html.
  • Find the temporary script near the bottom of index.html.
  • Replace its hard-coded configuration values with this object:
    const CONFIG = Object.freeze({
      initialCash: 25,
      revenueTarget: 100,
      defaultRiskThreshold: 5,
      autoApproveDelayMs: 2000,
      humanTimeoutSeconds: 10,
      betweenActionsMs: 850,
      storageKey: "agent-governance-audit-v1"
    });

What does this configuration control?

  • The initialCash value restores each run to $25.
  • The revenueTarget value keeps the target fixed at $100.
  • The defaultRiskThreshold value places the risk boundary at $5.
  • The autoApproveDelayMs value gives low-risk proposals a two-second review delay.
  • The remaining values reserve the timing and evidence settings used by the control room.
  • Save index.html.
  • Refresh the browser.

You should still see $25 of available cash. The policy card should show a $5 high-risk threshold.

Dashboard missing after the refresh?

Check that the closing }); remains directly after the final configuration property.

Make sure the configuration object stays inside the existing script element.

Ask for help checking the configuration syntax.

The catalog already contains the two proposals that exposed the overspending problem. Four more frozen proposals complete the scenario that the policy engine will govern.

  • Locate the Purchase Robotic Juicer entry inside the action catalog.
  • Paste these two objects immediately after that entry:
      Object.freeze({
        id: "rainy-discount",
        name: "Offer Rainy-Day Discount",
        description: "Run a small promotion to attract weather-sensitive customers.",
        cost: 2,
        projectedRevenue: 10
      }),
      Object.freeze({
        id: "lunch-rush",
        name: "Sell Lunch-Rush Lemonade",
        description: "Prepare a compact batch for the office lunch crowd.",
        cost: 3,
        projectedRevenue: 28
      }),

What do these proposals test?

Both proposals cost less than the default threshold. Their different revenue values let the dashboard show that approval can change cash and revenue together.

  • Locate the Sell Lunch-Rush Lemonade entry.
  • Paste these final two objects immediately after it:
      Object.freeze({
        id: "catering-booth",
        name: "Book Corporate Catering Booth",
        description: "Reserve a premium booth for a large workplace event.",
        cost: 12,
        projectedRevenue: 55
      }),
      Object.freeze({
        id: "afternoon-pitchers",
        name: "Sell Afternoon Pitchers",
        description: "Prepare a final batch to push revenue beyond the target.",
        cost: 4,
        projectedRevenue: 22
      })

Why freeze the catalog?

Each Object.freeze() call prevents the simulation from changing its test fixtures by accident. Stable proposals make policy results easier to compare.

  • Save index.html.
  • Refresh the browser.

The queue badge should show 0 of 6. This confirms that all six proposals are available to the runner.

Classify each proposal

Modular JavaScript gives each part of the dashboard one job. The UI module renders state. The simulation module owns decisions.

  • Move the dashboard rendering functions into UIModule.
  • Move the temporary simulation runner into SimulationModule with its state.
  • Save index.html.
  • Refresh the browser.

You should see the dashboard return with System Idle in the status pill. The starting balance should remain $25.

Why separate the modules?

The simulation decides what should happen. The UI translates that decision into visible labels and controls.

This separation keeps policy logic independent from presentation details. A policy change can stay small and testable.

  • Inside SimulationModule, add this classifier near the other simulation functions:
      function classify(action) {
        return action.cost < state.threshold ? "Low" : "High";
      }

How does the boundary work?

The comparison uses action.cost < state.threshold. A $4 action becomes Low under the default $5 threshold.

Any action costing $5 or more becomes High. The boundary includes actions costing exactly $5.

  • Find the part of the runner that selects the next action.
  • Replace its proposal setup with these state assignments:
        const action = ACTIONS[state.index];
        const risk = classify(action);
        state.activeAction = action;
        state.currentRisk = risk;
        state.countdown = null;

What does this state hold?

The runner stores the selected proposal in activeAction. It stores the classifier result in currentRisk.

The UI can now render the same risk result that drives the decision path. The badge and policy behavior stay aligned.

  • Save index.html.
  • Refresh the browser.

You should see Low risk: under $5 beside High risk: $5 and up in the policy panel.

Route decisions before money moves

Classification becomes a guardrail only when it controls execution. Low-risk actions can continue after a short delay. High-risk actions must stop before cash or revenue changes.

  • Inside the next-proposal function in SimulationModule, replace the unconditional execution path with this routing logic:
        if (risk === "Low") {
          state.status = "reviewing";
          UIModule.render(state);
          autoTimer = window.setTimeout(() => {
            resolveDecision("Auto-Approved", "System", true);
          }, CONFIG.autoApproveDelayMs);
          return;
        }

        state.status = "paused";
        state.countdown = CONFIG.humanTimeoutSeconds;
        UIModule.render(state);

What does this routing do?

  • The Low path renders an automated review state before starting the two-second timer.
  • The timer resolves the proposal as Auto-Approved through the system approver.
  • The High path changes the simulation status to paused.
  • The early return prevents low-risk proposals from falling into the human-review path.
  • Inside UIModule.render(), add this control state near the existing decision-panel rendering logic:
        const humanReview = state.status === "paused";
        elements.decisionZone.hidden = !humanReview;
        elements.approveButton.disabled = !humanReview;
        elements.denyButton.disabled = !humanReview;
        elements.countdownValue.textContent = String(state.countdown ?? CONFIG.humanTimeoutSeconds);

How does the review panel respond?

The decision panel becomes visible only while the state is paused. The same condition enables the Approve and Deny buttons.

A high-risk proposal remains pending because this path does not call the decision resolver. Cash and revenue stay unchanged while the human controls are active.

  • Save index.html.
  • Refresh the browser.

Before you start the run, which proposal do you expect the $5 threshold to pause?

  • Click Start Simulation.

After two seconds, you should see Buy Lemons resolve as Auto-Approved. Cash should change from $25 to $21.

The next proposal should be Purchase Robotic Juicer with a High badge. The simulation should pause with both decision buttons enabled.

  • Return to index.html in VS Code.
  • Find the unconditional cash and revenue update inside the decision resolver.
  • Wrap those updates with this approval guard:
        if (approved) {
          state.cash = state.cash - action.cost + action.projectedRevenue;
          state.revenue += action.projectedRevenue;
        }

Why guard the balance update?

The approved value becomes the final permission check before money moves. Denied proposals leave both balances unchanged.

A proposal can now be reviewed without granting it financial authority. The decision comes before the effect.

  • Add these decision functions inside SimulationModule after the resolver:
      function approve() {
        resolveDecision("Approved", "Human", true);
      }

      function deny() {
        resolveDecision("Denied", "Human", false);
      }

What do the decision functions pass?

The approval function sends true to the guarded resolver. The denial function sends false.

Both outcomes name Human as the approver. The source of authority stays visible in the simulation state.

  • Save index.html.
  • Refresh the browser.

You should see the control room return to System Idle with $25 available cash.

  • Connect the two dashboard controls by adding these event listeners beneath SimulationModule.
    elements.approveButton.addEventListener("click", SimulationModule.approve);
    elements.denyButton.addEventListener("click", SimulationModule.deny);

How do the controls reach the policy?

Each listener delegates the click to SimulationModule. The buttons do not change balances directly.

This keeps every financial change behind the same decision resolver. The interface cannot bypass the policy path.

  • Save index.html.
  • Return to the browser.
  • Refresh the page.

Before you start the final check, do you expect the robotic juicer to change the cash balance before you choose an outcome?

  • Click Start Simulation.

You should see Buy Lemons become Auto-Approved after two seconds. The robotic juicer should then pause with Approve and Deny enabled.

Cash should remain at $21 while the $60 proposal waits. That frozen balance proves the policy gate acts before the business state changes.

That is the central safety control working. Routine activity keeps moving while financial authority stays with a human.

Robotic juicer still spending automatically?

Check that classify(action) uses action.cost < state.threshold for the low-risk path.

Confirm that the paused path ends after UIModule.render(state) without resolving the proposal.

Ask for help tracing the risk route.

  • Click Deny while the robotic juicer is paused.

You should see Purchase Robotic Juicer: Denied. in the decision message. The cash balance should remain at $21 immediately after the decision.

Your agent now respects a deterministic risk boundary. Next, you will add a deadline that denies abandoned requests and preserves each outcome as durable audit evidence.

Fail Closed and Preserve Audit Evidence

Your control room now separates low-risk automation from high-risk human review. That boundary prevents the robotic juicer from spending cash before an operator decides.

A paused request can be forgotten. This step adds a fail-closed timeout that denies abandoned requests. It also uses LocalStorage to preserve an append-only audit log across browser refreshes.

In this step, get ready to:
  • Store an immutable-style record after every decision.
  • Deny unanswered high-risk proposals after ten seconds.
  • Reload saved audit evidence when the dashboard starts.
Store every audit decision

Each decision needs a durable record of what the agent proposed. The record also captures the policy result that governed it.

  • In index.html, find the end of the elements object.
  • Add the storage wrapper above UIModule by pasting this code:
const StorageModule = (() => {
  function load() {
    try {
      const raw = window.localStorage.getItem(CONFIG.storageKey);
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
    } catch (error) {
      console.warn("Audit log could not be loaded.", error);
      return [];
    }
  }
  function save(entries) {
    try {
      window.localStorage.setItem(CONFIG.storageKey, JSON.stringify(entries));
      return true;
    } catch (error) {
      console.warn("Audit log could not be saved.", error);
      return false;
    }
  }
  return Object.freeze({ load, save });
})();

What does this code do?

  • The load() function reads the value stored under CONFIG.storageKey.
  • The stored JSON becomes an array that the audit table can render.
  • The save() function serializes the growing array after each decision.
  • Both functions fall back safely when browser storage is unavailable.
  • Save index.html.
  • Switch back to the browser from earlier.
  • Refresh http://localhost:8000.

You should see the control room return with System Idle. This confirms that the storage wrapper loads without breaking the dashboard.

Dashboard missing after refresh?

Check that the closing })(); sits before UIModule. A missing brace prevents the rest of the script from loading.

Use this if the page remains blank. Help me find a syntax error in StorageModule.

The storage wrapper handles browser persistence. The audit module now defines the exact evidence saved after each policy decision.

  • Add AuditModule directly below StorageModule by pasting this code:
const AuditModule = (() => {
  let entries = StorageModule.load();
  let sequence = entries.length;
  function append(action, riskTier, outcome, approver, warning = "") {
    sequence += 1;
    const entry = Object.freeze({
      id: `audit-${Date.now()}-${sequence}`,
      timestamp: new Date().toISOString(),
      actionProposed: action.name,
      cost: action.cost,
      riskTier,
      outcome,
      approver,
      warning
    });
    entries = [...entries, entry];
    const saved = StorageModule.save(entries);
    UIModule.renderAudit(entries);
    if (!saved) {
      UIModule.showToast("Browser storage is unavailable. This session's rows will not survive refresh.", "warning");
    }
  }
  function getEntries() {
    return [...entries];
  }
  return Object.freeze({ append, getEntries });
})();

How does the audit trail grow?

  • Each entry records an identifier plus a timestamp.
  • The entry also records the proposed action plus its cost.
  • The policy evidence includes the risk tier plus the outcome.
  • The approver plus any safety warning complete the record.
  • Object.freeze() protects each record from accidental edits inside the application flow.
  • The spread expression creates a new array with the record appended at the end.
  • In SimulationModule, find the declaration for resolveDecision.
  • Replace its declaration with this version so a safety warning can travel into the audit record:
function resolveDecision(outcome, approver, approved, warning = "") {

Why add the warning parameter?

Human approvals do not need a safety warning. A timed-out request passes Default-Deny Fallback through this optional parameter.

  • Inside resolveDecision, find the block that updates cash when approved is true.
  • Add this audit call directly below that block:
AuditModule.append(action, risk, outcome, approver, warning);

Why record every outcome here?

Every automated or human decision passes through resolveDecision. Placing the audit call here creates one consistent evidence path for approvals plus denials plus timeouts.

  • Save index.html.
  • Switch back to the control room.
  • Click Reset Run if an earlier simulation is complete.
  • Click Start Simulation.
  • Wait for Purchase Robotic Juicer to enter human review.
  • Click Deny.

You should see a Denied row for the robotic juicer in the audit table. Good progress. Every resolved proposal now leaves evidence behind.

No audit row after denying?

Confirm that AuditModule.append(action, risk, outcome, approver, warning); sits inside resolveDecision after the approved-action block.

Use this if the decision completes without a row. Help me trace the missing audit entry.

Enforce the default-deny deadline

Human review protects high-risk spending only while someone is available to respond. A ten-second deadline gives abandoned proposals a safe outcome.

  • In SimulationModule, find the timer declarations above clearTimers().
  • Replace the declarations plus clearTimers() with this timer setup:
let autoTimer = null;
let countdownTimer = null;
let transitionTimer = null;

function clearTimers() {
  window.clearTimeout(autoTimer);
  window.clearTimeout(transitionTimer);
  window.clearInterval(countdownTimer);
  autoTimer = null;
  countdownTimer = null;
  transitionTimer = null;
}

Why track three timers?

  • The auto timer handles the two-second low-risk review.
  • The countdown timer updates the high-risk deadline once per second.
  • The transition timer moves the simulation to its next proposal.
  • The clearTimers() function prevents an old timer from resolving a later action.
  • In proposeNext(), find the high-risk branch after the low-risk branch returns.
  • Replace the current paused-state block with this countdown logic:
state.status = "paused";
state.countdown = CONFIG.humanTimeoutSeconds;
UIModule.render(state);

countdownTimer = window.setInterval(() => {
  state.countdown -= 1;
  UIModule.render(state);

  if (state.countdown <= 0) {
    resolveDecision("Timed Out", "System", false, "Default-Deny Fallback");
  }
}, 1000);

What happens at zero?

  • The interval reduces state.countdown once per second.
  • Each update renders the remaining time in the review panel.
  • At zero, the system records Timed Out with System as the approver.
  • The false value prevents the action from changing cash or revenue.
  • The existing transition then continues the simulation safely.
  • Save index.html.
  • Switch back to the control room.

Before you start the next run, do you expect the unanswered juicer request to change the available cash?

  • Click Reset Run.
  • Click Start Simulation.
  • Keep the browser tab visible while the timers run.
  • Leave Purchase Robotic Juicer unanswered.

The ten-second wait is deliberate. You should see the countdown reach zero before the review panel closes.

The audit table should show Timed Out with System as the approver. The row should also display Default-Deny Fallback while the simulation continues without paying for the juicer.

Countdown stuck at ten?

Check that state.countdown -= 1; runs inside window.setInterval. Confirm that each interval calls UIModule.render(state);.

Use this if the timer does not update. Help me debug the fail-closed countdown.

Reload the saved evidence

Saving records after each decision handles one half of persistence. The dashboard must also render those stored records when the page starts.

  • Scroll to the startup calls at the bottom of index.html.
  • Replace the existing simulation render call with these two startup calls:
UIModule.renderAudit(AuditModule.getEntries());
UIModule.render(SimulationModule.getState());

How does startup restoration work?

AuditModule loads its entries from the agent-governance-audit-v1 storage key when the script starts. The first call rebuilds the table before the simulation renders its idle state.

  • Save index.html.

Before you refresh, do you expect the timed-out row to disappear or return from browser storage?

  • Refresh http://localhost:8000.
  • Find the Purchase Robotic Juicer row in the audit table.
  • Confirm that its outcome remains Timed Out.
  • Confirm that its approver remains System.
  • Confirm that the row still shows Default-Deny Fallback.

That is the durability check passed. Your control room now preserves decision evidence across refreshes while abandoned high-risk spending fails closed.

How strong is this audit pattern?

The application only adds records through AuditModule.append. Each entry is frozen before joining a new array.

Browser storage remains controlled by the person using the browser. This dashboard demonstrates append-only application behavior without claiming tamper-proof storage.

✔️ Awesome, I've got everything!

Your saved file now contains persistent audit storage plus a ten-second default-deny timer. The timed-out row survives a browser refresh.

ⓧ I'd like to double check the full code

Compare your finished index.html against these checkpoints.

  • Confirm that StorageModule loads plus saves records under agent-governance-audit-v1.
  • Confirm that AuditModule.append records the timestamp plus proposal plus cost plus risk tier plus outcome plus approver plus warning.
  • Confirm that resolveDecision calls AuditModule.append after deciding whether to update cash plus revenue.
  • Confirm that the countdown resolves with Timed Out plus System plus Default-Deny Fallback.
  • Confirm that startup calls UIModule.renderAudit(AuditModule.getEntries()) before rendering the simulation state.

Your control room now fails closed plus preserves its evidence. Next, you will add a safe Slack Block Kit escalation mock without transmitting or storing a webhook secret.

Mock a Slack Escalation Safely

Your fail-closed timer now protects unattended high-risk decisions. The audit log also preserves evidence when the system denies an unanswered proposal.

An operator can still miss a dashboard-only pause. A Slack Block Kit notification can draw attention to the review. However, placing a webhook secret in browser code would expose it. You will finish the control room with a local mock that demonstrates the escalation without transmitting or storing a secret in LocalStorage.

In this step, get ready to:
  • Build a local Slack-style escalation with structured action details.
  • Preview the JSON payload when a high-risk proposal pauses.
  • Prove that the optional webhook value is never transmitted or persisted.
Complete the safe escalation layer

The escalation layer needs three coordinated pieces. The page provides a masked demo field. NotificationModule.buildPayload() creates the payload. UIModule.showSlackMock() renders that payload locally.

The completed file keeps the real decision path attached only to #approveButton and #denyButton. The mock payload can display button definitions without making those preview buttons operational.

  • Switch back to index.html in VS Code.
  • Compare your current file with the completed version in the second tab below.
  • Add the missing Slack mock markup plus its styles.
  • Add NotificationModule between AuditModule and UIModule.
  • Add the Slack element references plus the dismiss listener.
  • Add the call to NotificationModule.raise(action, state.threshold) inside the high-risk branch of proposeNext().

✔️ Awesome, I've got everything!

Keep your current file open. Confirm that your additions match the names and positions described above.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Agent Governance Control Room</title>
  <style>
    :root {
      color-scheme: dark;
      --bg: #07111f;
      --panel: #0d1b2d;
      --panel-2: #12243a;
      --line: #243955;
      --text: #e7eef8;
      --muted: #91a4bd;
      --blue: #4da3ff;
      --green: #36d399;
      --amber: #fbbf24;
      --red: #fb7185;
      --purple: #a78bfa;
      --shadow: 0 18px 55px rgba(0, 0, 0, 0.28);
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      min-height: 100vh;
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background:
        radial-gradient(circle at top right, rgba(77, 163, 255, 0.13), transparent 32rem),
        var(--bg);
      color: var(--text);
    }

    button,
    input {
      font: inherit;
    }

    button {
      border: 0;
      cursor: pointer;
    }

    button:disabled,
    input:disabled {
      cursor: not-allowed;
      opacity: 0.5;
    }

    .app-shell {
      width: min(1500px, 100%);
      margin: 0 auto;
      padding: 24px;
    }

    .topbar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 20px;
      margin-bottom: 22px;
    }

    .brand {
      display: flex;
      align-items: center;
      gap: 14px;
    }

    .brand-mark {
      display: grid;
      place-items: center;
      width: 44px;
      height: 44px;
      border: 1px solid rgba(77, 163, 255, 0.45);
      border-radius: 14px;
      background: rgba(77, 163, 255, 0.12);
      color: var(--blue);
      font-weight: 900;
    }

    h1,
    h2,
    h3,
    p {
      margin-top: 0;
    }

    h1 {
      margin-bottom: 4px;
      font-size: clamp(1.35rem, 3vw, 2rem);
    }

    h2 {
      margin-bottom: 6px;
      font-size: 1.05rem;
    }

    .eyebrow,
    .muted,
    .helper {
      color: var(--muted);
    }

    .eyebrow {
      margin-bottom: 6px;
      font-size: 0.72rem;
      font-weight: 800;
      letter-spacing: 0.12em;
      text-transform: uppercase;
    }

    .top-actions,
    .decision-actions {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
    }

    .button {
      min-height: 42px;
      padding: 0 16px;
      border-radius: 10px;
      background: var(--blue);
      color: #04101f;
      font-weight: 800;
    }

    .button.secondary {
      border: 1px solid var(--line);
      background: transparent;
      color: var(--text);
    }

    .button.approve {
      background: var(--green);
      color: #03241a;
    }

    .button.deny {
      background: var(--red);
      color: #2d0710;
    }

    .button.small {
      min-height: 34px;
      padding: 0 12px;
      font-size: 0.82rem;
    }

    .status-pill,
    .badge {
      display: inline-flex;
      align-items: center;
      gap: 7px;
      width: fit-content;
      border-radius: 999px;
      font-size: 0.76rem;
      font-weight: 800;
      letter-spacing: 0.02em;
    }

    .status-pill {
      padding: 8px 11px;
      border: 1px solid var(--line);
      background: rgba(145, 164, 189, 0.08);
      color: var(--muted);
    }

    .status-pill::before {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      background: currentColor;
      content: "";
    }

    .status-pill[data-state="running"],
    .status-pill[data-state="reviewing"] {
      color: var(--blue);
    }

    .status-pill[data-state="paused"] {
      color: var(--amber);
    }

    .status-pill[data-state="completed"] {
      color: var(--green);
    }

    .summary-grid {
      display: grid;
      grid-template-columns: repeat(4, minmax(0, 1fr));
      gap: 14px;
      margin-bottom: 14px;
    }

    .card {
      border: 1px solid var(--line);
      border-radius: 16px;
      background: linear-gradient(180deg, rgba(18, 36, 58, 0.9), rgba(13, 27, 45, 0.96));
      box-shadow: var(--shadow);
    }

    .stat-card {
      min-height: 128px;
      padding: 18px;
    }

    .stat-label {
      color: var(--muted);
      font-size: 0.78rem;
      font-weight: 800;
      letter-spacing: 0.08em;
      text-transform: uppercase;
    }

    .stat-value {
      margin: 8px 0 10px;
      font-size: 1.9rem;
      font-weight: 900;
    }

    .stat-value.danger {
      color: var(--red);
    }

    .progress-track {
      height: 8px;
      overflow: hidden;
      border-radius: 999px;
      background: #07111f;
    }

    .progress-bar {
      width: 0;
      height: 100%;
      border-radius: inherit;
      background: linear-gradient(90deg, var(--blue), var(--green));
      transition: width 240ms ease;
    }

    .threshold-control {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-top: 10px;
    }

    .threshold-control input,
    .webhook-input {
      width: 100%;
      border: 1px solid var(--line);
      border-radius: 10px;
      outline: none;
      background: #07111f;
      color: var(--text);
    }

    .threshold-control input {
      max-width: 92px;
      padding: 9px 10px;
    }

    .threshold-control input:focus,
    .webhook-input:focus {
      border-color: var(--blue);
      box-shadow: 0 0 0 3px rgba(77, 163, 255, 0.12);
    }

    .workspace {
      display: grid;
      grid-template-columns: minmax(0, 0.9fr) minmax(560px, 1.4fr);
      gap: 14px;
    }

    .stack {
      display: grid;
      align-content: start;
      gap: 14px;
    }

    .panel {
      padding: 20px;
    }

    .panel-header {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 14px;
      margin-bottom: 18px;
    }

    .proposal-card {
      min-height: 340px;
    }

    .proposal-body {
      display: grid;
      gap: 18px;
    }

    .proposal-title {
      margin-bottom: 7px;
      font-size: 1.45rem;
    }

    .badge-row {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
    }

    .badge {
      padding: 6px 9px;
      border: 1px solid transparent;
    }

    .badge.neutral {
      border-color: var(--line);
      background: rgba(145, 164, 189, 0.08);
      color: var(--muted);
    }

    .badge.low,
    .badge.auto-approved,
    .badge.approved {
      border-color: rgba(54, 211, 153, 0.35);
      background: rgba(54, 211, 153, 0.1);
      color: var(--green);
    }

    .badge.high,
    .badge.timed-out {
      border-color: rgba(251, 191, 36, 0.4);
      background: rgba(251, 191, 36, 0.1);
      color: var(--amber);
    }

    .badge.denied {
      border-color: rgba(251, 113, 133, 0.4);
      background: rgba(251, 113, 133, 0.1);
      color: var(--red);
    }

    .decision-zone {
      display: grid;
      grid-template-columns: 112px 1fr;
      gap: 16px;
      align-items: center;
      padding: 16px;
      border: 1px solid rgba(251, 191, 36, 0.35);
      border-radius: 14px;
      background: rgba(251, 191, 36, 0.06);
    }

    .countdown {
      display: grid;
      place-items: center;
      width: 100px;
      height: 100px;
      border: 7px solid rgba(251, 191, 36, 0.25);
      border-top-color: var(--amber);
      border-radius: 50%;
      color: var(--amber);
      font-size: 2.2rem;
      font-weight: 900;
    }

    .policy-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 12px;
    }

    .policy-rule {
      padding: 14px;
      border: 1px solid var(--line);
      border-radius: 12px;
      background: rgba(7, 17, 31, 0.45);
    }

    .policy-rule strong {
      display: block;
      margin-bottom: 5px;
    }

    .audit-card {
      min-height: 520px;
    }

    .table-wrap {
      max-height: 560px;
      overflow: auto;
      border: 1px solid var(--line);
      border-radius: 12px;
    }

    table {
      width: 100%;
      border-collapse: collapse;
      font-size: 0.82rem;
    }

    th,
    td {
      padding: 12px 10px;
      border-bottom: 1px solid var(--line);
      text-align: left;
      vertical-align: top;
    }

    th {
      position: sticky;
      top: 0;
      z-index: 1;
      background: #102139;
      color: var(--muted);
      font-size: 0.7rem;
      letter-spacing: 0.06em;
      text-transform: uppercase;
    }

    tbody tr:last-child td {
      border-bottom: 0;
    }

    .empty-cell {
      padding: 40px 16px;
      color: var(--muted);
      text-align: center;
    }

    .warning-text {
      margin-top: 6px;
      color: var(--amber);
      font-size: 0.72rem;
      font-weight: 800;
    }

    .webhook-input {
      padding: 11px 12px;
    }

    .secret-note {
      margin: 10px 0 14px;
      color: var(--amber);
      font-size: 0.78rem;
    }

    .slack-mock {
      margin-top: 14px;
      padding: 14px;
      border: 1px solid rgba(167, 139, 250, 0.4);
      border-radius: 12px;
      background: rgba(167, 139, 250, 0.08);
    }

    .mock-header {
      display: flex;
      justify-content: space-between;
      gap: 12px;
    }

    .mock-chip {
      color: var(--purple);
      font-size: 0.76rem;
      font-weight: 900;
    }

    pre {
      max-height: 280px;
      overflow: auto;
      margin: 12px 0 0;
      padding: 12px;
      border: 1px solid var(--line);
      border-radius: 10px;
      background: #050c16;
      color: #cfe4ff;
      font-size: 0.75rem;
      white-space: pre-wrap;
      word-break: break-word;
    }

    .toast-region {
      position: fixed;
      right: 22px;
      bottom: 22px;
      z-index: 10;
      display: grid;
      width: min(390px, calc(100vw - 44px));
      gap: 10px;
    }

    .toast {
      padding: 14px 16px;
      border: 1px solid var(--line);
      border-left: 4px solid var(--blue);
      border-radius: 12px;
      background: #102139;
      box-shadow: var(--shadow);
    }

    .toast.warning {
      border-left-color: var(--amber);
    }

    .toast.success {
      border-left-color: var(--green);
    }

    [hidden] {
      display: none !important;
    }

    @media (max-width: 1050px) {
      .summary-grid {
        grid-template-columns: repeat(2, 1fr);
      }

      .workspace {
        grid-template-columns: 1fr;
      }
    }

    @media (max-width: 650px) {
      .app-shell {
        padding: 14px;
      }

      .topbar {
        align-items: flex-start;
        flex-direction: column;
      }

      .summary-grid,
      .policy-grid {
        grid-template-columns: 1fr;
      }

      .decision-zone {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>
<body>
  <div class="app-shell">
    <header class="topbar">
      <div class="brand">
        <div class="brand-mark" aria-hidden="true">AG</div>
        <div>
          <p class="eyebrow">Lemonade Operations</p>
          <h1>Agent Governance Control Room</h1>
          <div id="systemStatus" class="status-pill" data-state="idle">System Idle</div>
        </div>
      </div>
      <div class="top-actions">
        <button id="startButton" class="button" type="button">Start Simulation</button>
        <button id="resetButton" class="button secondary" type="button">Reset Run</button>
      </div>
    </header>

    <section class="summary-grid" aria-label="Simulation summary">
      <article class="card stat-card">
        <div class="stat-label">Available Cash</div>
        <div id="cashValue" class="stat-value">$25</div>
        <div class="helper">Starting balance: $25</div>
      </article>

      <article class="card stat-card">
        <div class="stat-label">Revenue</div>
        <div id="revenueValue" class="stat-value">$0</div>
        <div class="progress-track" aria-label="Revenue target progress">
          <div id="revenueProgress" class="progress-bar"></div>
        </div>
      </article>

      <article class="card stat-card">
        <div class="stat-label">Revenue Target</div>
        <div class="stat-value">$100</div>
        <div id="targetStatus" class="helper">$100 remaining</div>
      </article>

      <article class="card stat-card">
        <label class="stat-label" for="thresholdInput">High-Risk Threshold</label>
        <div class="threshold-control">
          <span>$</span>
          <input id="thresholdInput" type="number" min="1" step="1" value="5">
        </div>
        <div class="helper">Actions at or above this cost require review.</div>
      </article>
    </section>

    <main class="workspace">
      <div class="stack">
        <section class="card panel proposal-card" aria-labelledby="proposalHeading">
          <div class="panel-header">
            <div>
              <p class="eyebrow">Live Decision Queue</p>
              <h2 id="proposalHeading">Current Proposal</h2>
            </div>
            <span id="queuePosition" class="badge neutral">0 of 6</span>
          </div>

          <div class="proposal-body">
            <div>
              <h3 id="actionName" class="proposal-title">Waiting to start</h3>
              <p id="actionDescription" class="muted">Start the simulation to receive the first agent proposal.</p>
              <div class="badge-row">
                <span id="costBadge" class="badge neutral">Cost: $0</span>
                <span id="riskBadge" class="badge neutral">Risk: Not assessed</span>
                <span id="revenueBadge" class="badge neutral">Projected revenue: $0</span>
              </div>
            </div>

            <div id="decisionZone" class="decision-zone" hidden>
              <div id="countdownValue" class="countdown" aria-live="assertive">10</div>
              <div>
                <p class="eyebrow">Human Decision Required</p>
                <h3>Fail-closed timer active</h3>
                <p class="muted">If no operator responds, the system will deny this action and continue safely.</p>
                <div class="decision-actions">
                  <button id="approveButton" class="button approve" type="button">Approve</button>
                  <button id="denyButton" class="button deny" type="button">Deny</button>
                </div>
              </div>
            </div>

            <p id="decisionMessage" class="helper" aria-live="polite">No action is currently under review.</p>
          </div>
        </section>

        <section class="card panel" aria-labelledby="policyHeading">
          <div class="panel-header">
            <div>
              <p class="eyebrow">Policy Engine</p>
              <h2 id="policyHeading">Threshold Gates</h2>
            </div>
          </div>
          <div class="policy-grid">
            <div class="policy-rule">
              <strong id="lowPolicyLabel">Low risk: under $5</strong>
              <span class="muted">System auto-approves after a two-second review delay.</span>
            </div>
            <div class="policy-rule">
              <strong id="highPolicyLabel">High risk: $5 and up</strong>
              <span class="muted">Simulation pauses for human approval or default-deny.</span>
            </div>
          </div>
        </section>

        <section class="card panel" aria-labelledby="slackHeading">
          <div class="panel-header">
            <div>
              <p class="eyebrow">Escalation Channel</p>
              <h2 id="slackHeading">Slack Interactive Webhook Mock</h2>
            </div>
            <span class="badge neutral">Safe Mock</span>
          </div>

          <label class="helper" for="webhookInput">Slack Webhook URL (optional demo input)</label>
          <input
            id="webhookInput"
            class="webhook-input"
            type="password"
            autocomplete="off"
            placeholder="Not required for this project"
          >
          <p class="secret-note">This value stays only in the input. The dashboard never transmits, logs, or stores it.</p>

          <div id="slackMock" class="slack-mock" hidden>
            <div class="mock-header">
              <div>
                <div class="mock-chip">Slack safety alert mock</div>
                <strong>High-risk action needs review</strong>
              </div>
              <button id="dismissMockButton" class="button secondary small" type="button">Dismiss</button>
            </div>
            <p id="webhookMode" class="muted"></p>
            <pre id="jsonPreview" aria-label="Slack Block Kit JSON preview"></pre>
          </div>
        </section>
      </div>

      <section class="card panel audit-card" aria-labelledby="auditHeading">
        <div class="panel-header">
          <div>
            <p class="eyebrow">Persistent Evidence</p>
            <h2 id="auditHeading">Append-Only Audit Log</h2>
          </div>
          <span id="auditCount" class="badge neutral">0 events</span>
        </div>

        <div class="table-wrap">
          <table>
            <thead>
              <tr>
                <th>Timestamp</th>
                <th>Action Proposed</th>
                <th>Cost</th>
                <th>Risk Tier</th>
                <th>Outcome</th>
                <th>Approver</th>
              </tr>
            </thead>
            <tbody id="auditBody"></tbody>
          </table>
        </div>
      </section>
    </main>
  </div>

  <div id="toastRegion" class="toast-region" aria-live="assertive"></div>

  <script>
    "use strict";

    const CONFIG = Object.freeze({
      initialCash: 25,
      revenueTarget: 100,
      defaultRiskThreshold: 5,
      autoApproveDelayMs: 2000,
      humanTimeoutSeconds: 10,
      betweenActionsMs: 850,
      storageKey: "agent-governance-audit-v1"
    });

    const ACTIONS = Object.freeze([
      Object.freeze({
        id: "buy-lemons",
        name: "Buy Lemons",
        description: "Restock fresh lemons for the first sales batch.",
        cost: 4,
        projectedRevenue: 0
      }),
      Object.freeze({
        id: "robotic-juicer",
        name: "Purchase Robotic Juicer",
        description: "Automate production with equipment that exceeds current cash.",
        cost: 60,
        projectedRevenue: 0
      }),
      Object.freeze({
        id: "rainy-discount",
        name: "Offer Rainy-Day Discount",
        description: "Run a small promotion to attract weather-sensitive customers.",
        cost: 2,
        projectedRevenue: 10
      }),
      Object.freeze({
        id: "lunch-rush",
        name: "Sell Lunch-Rush Lemonade",
        description: "Prepare a compact batch for the office lunch crowd.",
        cost: 3,
        projectedRevenue: 28
      }),
      Object.freeze({
        id: "catering-booth",
        name: "Book Corporate Catering Booth",
        description: "Reserve a premium booth for a large workplace event.",
        cost: 12,
        projectedRevenue: 55
      }),
      Object.freeze({
        id: "afternoon-pitchers",
        name: "Sell Afternoon Pitchers",
        description: "Prepare a final batch to push revenue beyond the target.",
        cost: 4,
        projectedRevenue: 22
      })
    ]);

    const elements = Object.freeze({
      systemStatus: document.querySelector("#systemStatus"),
      startButton: document.querySelector("#startButton"),
      resetButton: document.querySelector("#resetButton"),
      cashValue: document.querySelector("#cashValue"),
      revenueValue: document.querySelector("#revenueValue"),
      revenueProgress: document.querySelector("#revenueProgress"),
      targetStatus: document.querySelector("#targetStatus"),
      thresholdInput: document.querySelector("#thresholdInput"),
      lowPolicyLabel: document.querySelector("#lowPolicyLabel"),
      highPolicyLabel: document.querySelector("#highPolicyLabel"),
      queuePosition: document.querySelector("#queuePosition"),
      actionName: document.querySelector("#actionName"),
      actionDescription: document.querySelector("#actionDescription"),
      costBadge: document.querySelector("#costBadge"),
      riskBadge: document.querySelector("#riskBadge"),
      revenueBadge: document.querySelector("#revenueBadge"),
      decisionZone: document.querySelector("#decisionZone"),
      countdownValue: document.querySelector("#countdownValue"),
      approveButton: document.querySelector("#approveButton"),
      denyButton: document.querySelector("#denyButton"),
      decisionMessage: document.querySelector("#decisionMessage"),
      webhookInput: document.querySelector("#webhookInput"),
      slackMock: document.querySelector("#slackMock"),
      webhookMode: document.querySelector("#webhookMode"),
      jsonPreview: document.querySelector("#jsonPreview"),
      dismissMockButton: document.querySelector("#dismissMockButton"),
      auditBody: document.querySelector("#auditBody"),
      auditCount: document.querySelector("#auditCount"),
      toastRegion: document.querySelector("#toastRegion")
    });

    const StorageModule = (() => {
      function load() {
        try {
          const raw = window.localStorage.getItem(CONFIG.storageKey);
          if (!raw) return [];
          const parsed = JSON.parse(raw);
          return Array.isArray(parsed) ? parsed : [];
        } catch (error) {
          console.warn("Audit log could not be loaded.", error);
          return [];
        }
      }

      function save(entries) {
        try {
          window.localStorage.setItem(CONFIG.storageKey, JSON.stringify(entries));
          return true;
        } catch (error) {
          console.warn("Audit log could not be saved.", error);
          return false;
        }
      }

      return Object.freeze({ load, save });
    })();

    const AuditModule = (() => {
      let entries = StorageModule.load();
      let sequence = entries.length;

      function append(action, riskTier, outcome, approver, warning = "") {
        sequence += 1;
        const entry = Object.freeze({
          id: `audit-${Date.now()}-${sequence}`,
          timestamp: new Date().toISOString(),
          actionProposed: action.name,
          cost: action.cost,
          riskTier,
          outcome,
          approver,
          warning
        });

        entries = [...entries, entry];
        const saved = StorageModule.save(entries);
        UIModule.renderAudit(entries);

        if (!saved) {
          UIModule.showToast("Browser storage is unavailable. This session's rows will not survive refresh.", "warning");
        }
      }

      function getEntries() {
        return [...entries];
      }

      return Object.freeze({ append, getEntries });
    })();

    const NotificationModule = (() => {
      function buildPayload(action, threshold) {
        return {
          text: `High-risk agent action requires review: ${action.name}`,
          blocks: [
            {
              type: "header",
              text: {
                type: "plain_text",
                text: "Agent approval required"
              }
            },
            {
              type: "section",
              fields: [
                {
                  type: "mrkdwn",
                  text: `*Action*\n${action.name}`
                },
                {
                  type: "mrkdwn",
                  text: `*Cost*\n$${action.cost}`
                },
                {
                  type: "mrkdwn",
                  text: `*Risk tier*\nHigh ($${threshold}+)`
                },
                {
                  type: "mrkdwn",
                  text: `*Fallback*\nDeny after ${CONFIG.humanTimeoutSeconds}s`
                }
              ]
            },
            {
              type: "actions",
              elements: [
                {
                  type: "button",
                  text: {
                    type: "plain_text",
                    text: "Approve"
                  },
                  style: "primary",
                  value: action.id,
                  action_id: "approve_agent_action"
                },
                {
                  type: "button",
                  text: {
                    type: "plain_text",
                    text: "Deny"
                  },
                  style: "danger",
                  value: action.id,
                  action_id: "deny_agent_action"
                }
              ]
            }
          ]
        };
      }

      function raise(action, threshold) {
        const payload = buildPayload(action, threshold);
        const hasWebhookValue = elements.webhookInput.value.trim().length > 0;
        UIModule.showSlackMock(payload, hasWebhookValue);
      }

      return Object.freeze({ buildPayload, raise });
    })();

    const UIModule = (() => {
      function money(value) {
        return `$${value.toFixed(0)}`;
      }

      function statusCopy(status) {
        const labels = {
          idle: "System Idle",
          running: "Agent Running",
          reviewing: "Automated Review",
          paused: "Human Review Required",
          completed: "Simulation Complete"
        };
        return labels[status] || "System Idle";
      }

      function render(state) {
        elements.systemStatus.textContent = statusCopy(state.status);
        elements.systemStatus.dataset.state = state.status;
        elements.cashValue.textContent = money(state.cash);
        elements.cashValue.classList.toggle("danger", state.cash < 0);
        elements.revenueValue.textContent = money(state.revenue);

        const progress = Math.min((state.revenue / CONFIG.revenueTarget) * 100, 100);
        elements.revenueProgress.style.width = `${progress}%`;
        const remaining = Math.max(CONFIG.revenueTarget - state.revenue, 0);
        elements.targetStatus.textContent = remaining === 0
          ? "Target reached"
          : `${money(remaining)} remaining`;

        elements.thresholdInput.value = String(state.threshold);
        elements.lowPolicyLabel.textContent = `Low risk: under $${state.threshold}`;
        elements.highPolicyLabel.textContent = `High risk: $${state.threshold} and up`;
        elements.queuePosition.textContent = `${Math.min(state.index + (state.activeAction ? 1 : 0), ACTIONS.length)} of ${ACTIONS.length}`;

        const controlsLocked = ["running", "reviewing", "paused"].includes(state.status);
        elements.startButton.disabled = controlsLocked;
        elements.resetButton.disabled = controlsLocked || state.status === "idle";
        elements.thresholdInput.disabled = controlsLocked;
        elements.startButton.textContent = state.status === "completed" ? "Run Again" : "Start Simulation";

        if (state.activeAction) {
          elements.actionName.textContent = state.activeAction.name;
          elements.actionDescription.textContent = state.activeAction.description;
          elements.costBadge.textContent = `Cost: ${money(state.activeAction.cost)}`;
          elements.revenueBadge.textContent = `Projected revenue: ${money(state.activeAction.projectedRevenue)}`;
          elements.riskBadge.textContent = `Risk: ${state.currentRisk}`;
          elements.riskBadge.className = `badge ${state.currentRisk.toLowerCase()}`;
        } else {
          elements.costBadge.textContent = "Cost: $0";
          elements.revenueBadge.textContent = "Projected revenue: $0";
          elements.riskBadge.textContent = "Risk: Not assessed";
          elements.riskBadge.className = "badge neutral";

          if (state.status === "completed") {
            elements.actionName.textContent = "All proposals processed";
            elements.actionDescription.textContent = "Review the audit log to inspect every governance decision.";
          } else if (state.status === "running") {
            elements.actionName.textContent = "Preparing next proposal";
            elements.actionDescription.textContent = "The agent is moving to the next business action.";
          } else {
            elements.actionName.textContent = "Waiting to start";
            elements.actionDescription.textContent = "Start the simulation to receive the first agent proposal.";
          }
        }

        const humanReview = state.status === "paused";
        elements.decisionZone.hidden = !humanReview;
        elements.approveButton.disabled = !humanReview;
        elements.denyButton.disabled = !humanReview;
        elements.countdownValue.textContent = String(state.countdown ?? CONFIG.humanTimeoutSeconds);

        if (state.status === "reviewing") {
          elements.decisionMessage.textContent = "Low-risk gate passed. Auto-approval completes after two seconds.";
        } else if (humanReview) {
          elements.decisionMessage.textContent = "Simulation paused. Cash and revenue will not change until this decision resolves.";
        } else if (state.lastDecision) {
          elements.decisionMessage.textContent = `${state.lastDecision.actionName}: ${state.lastDecision.outcome}.`;
        } else {
          elements.decisionMessage.textContent = "No action is currently under review.";
        }
      }

      function outcomeClass(outcome) {
        return outcome.toLowerCase().replace(/[^a-z]+/g, "-");
      }

      function makeCell(text) {
        const cell = document.createElement("td");
        cell.textContent = text;
        return cell;
      }

      function renderAudit(entries) {
        elements.auditBody.replaceChildren();
        elements.auditCount.textContent = `${entries.length} ${entries.length === 1 ? "event" : "events"}`;

        if (entries.length === 0) {
          const row = document.createElement("tr");
          const cell = document.createElement("td");
          cell.colSpan = 6;
          cell.className = "empty-cell";
          cell.textContent = "No decisions recorded yet. Start the simulation to create evidence.";
          row.append(cell);
          elements.auditBody.append(row);
          return;
        }

        [...entries].reverse().forEach((entry) => {
          const row = document.createElement("tr");
          const timestamp = new Date(entry.timestamp).toLocaleTimeString([], {
            hour: "2-digit",
            minute: "2-digit",
            second: "2-digit"
          });

          row.append(makeCell(timestamp));
          row.append(makeCell(entry.actionProposed));
          row.append(makeCell(money(Number(entry.cost))));

          const riskCell = document.createElement("td");
          const riskBadge = document.createElement("span");
          riskBadge.className = `badge ${String(entry.riskTier).toLowerCase()}`;
          riskBadge.textContent = entry.riskTier;
          riskCell.append(riskBadge);
          row.append(riskCell);

          const outcomeCell = document.createElement("td");
          const outcomeBadge = document.createElement("span");
          outcomeBadge.className = `badge ${outcomeClass(entry.outcome)}`;
          outcomeBadge.textContent = entry.outcome;
          outcomeCell.append(outcomeBadge);

          if (entry.warning) {
            const warning = document.createElement("div");
            warning.className = "warning-text";
            warning.textContent = entry.warning;
            outcomeCell.append(warning);
          }

          row.append(outcomeCell);
          row.append(makeCell(entry.approver));
          elements.auditBody.append(row);
        });
      }

      function showSlackMock(payload, hasWebhookValue) {
        elements.jsonPreview.textContent = JSON.stringify(payload, null, 2);
        elements.webhookMode.textContent = hasWebhookValue
          ? "Safe mock mode: a URL was detected, but it was not transmitted or stored."
          : "No webhook configured: the alert is rendered locally.";
        elements.slackMock.hidden = false;
        showToast("High-risk escalation mock generated.", "warning");
      }

      function showToast(message, tone = "info") {
        const toast = document.createElement("div");
        toast.className = `toast ${tone}`;
        toast.textContent = message;
        elements.toastRegion.append(toast);
        window.setTimeout(() => toast.remove(), 4200);
      }

      function getThreshold() {
        const value = Number(elements.thresholdInput.value);
        return Number.isFinite(value) && value >= 1
          ? Math.floor(value)
          : CONFIG.defaultRiskThreshold;
      }

      return Object.freeze({
        render,
        renderAudit,
        showSlackMock,
        showToast,
        getThreshold
      });
    })();

    const SimulationModule = (() => {
      function initialState(threshold = CONFIG.defaultRiskThreshold) {
        return {
          cash: CONFIG.initialCash,
          revenue: 0,
          threshold,
          index: 0,
          status: "idle",
          activeAction: null,
          currentRisk: null,
          countdown: null,
          lastDecision: null
        };
      }

      let state = initialState();
      let autoTimer = null;
      let countdownTimer = null;
      let transitionTimer = null;

      function clearTimers() {
        window.clearTimeout(autoTimer);
        window.clearTimeout(transitionTimer);
        window.clearInterval(countdownTimer);
        autoTimer = null;
        countdownTimer = null;
        transitionTimer = null;
      }

      function classify(action) {
        return action.cost < state.threshold ? "Low" : "High";
      }

      function start() {
        if (["running", "reviewing", "paused"].includes(state.status)) return;
        if (state.status === "completed") reset();
        state.status = "running";
        UIModule.render(state);
        proposeNext();
      }

      function reset() {
        clearTimers();
        state = initialState(UIModule.getThreshold());
        elements.slackMock.hidden = true;
        UIModule.render(state);
      }

      function setThreshold() {
        if (!["idle", "completed"].includes(state.status)) return;
        state.threshold = UIModule.getThreshold();
        UIModule.render(state);
      }

      function proposeNext() {
        if (state.index >= ACTIONS.length) {
          state.status = "completed";
          state.activeAction = null;
          state.currentRisk = null;
          UIModule.render(state);
          UIModule.showToast("Simulation complete. Review the audit evidence.", "success");
          return;
        }

        const action = ACTIONS[state.index];
        const risk = classify(action);
        state.activeAction = action;
        state.currentRisk = risk;
        state.countdown = null;

        if (risk === "Low") {
          state.status = "reviewing";
          UIModule.render(state);
          autoTimer = window.setTimeout(() => {
            resolveDecision("Auto-Approved", "System", true);
          }, CONFIG.autoApproveDelayMs);
          return;
        }

        state.status = "paused";
        state.countdown = CONFIG.humanTimeoutSeconds;
        UIModule.render(state);
        NotificationModule.raise(action, state.threshold);

        countdownTimer = window.setInterval(() => {
          state.countdown -= 1;
          UIModule.render(state);

          if (state.countdown <= 0) {
            resolveDecision("Timed Out", "System", false, "Default-Deny Fallback");
          }
        }, 1000);
      }

      function resolveDecision(outcome, approver, approved, warning = "") {
        if (!["reviewing", "paused"].includes(state.status) || !state.activeAction) return;

        const action = state.activeAction;
        const risk = state.currentRisk;
        clearTimers();

        if (approved) {
          state.cash = state.cash - action.cost + action.projectedRevenue;
          state.revenue += action.projectedRevenue;
        }

        AuditModule.append(action, risk, outcome, approver, warning);
        state.index += 1;
        state.activeAction = null;
        state.currentRisk = null;
        state.countdown = null;
        state.status = "running";
        state.lastDecision = {
          actionName: action.name,
          outcome
        };

        UIModule.render(state);

        if (warning) {
          UIModule.showToast(`${warning}: ${action.name} was rejected.`, "warning");
        } else {
          UIModule.showToast(`${action.name}: ${outcome}.`, approved ? "success" : "warning");
        }

        transitionTimer = window.setTimeout(proposeNext, CONFIG.betweenActionsMs);
      }

      function approve() {
        resolveDecision("Approved", "Human", true);
      }

      function deny() {
        resolveDecision("Denied", "Human", false);
      }

      function getState() {
        return { ...state };
      }

      return Object.freeze({
        start,
        reset,
        setThreshold,
        approve,
        deny,
        getState
      });
    })();

    elements.startButton.addEventListener("click", SimulationModule.start);
    elements.resetButton.addEventListener("click", SimulationModule.reset);
    elements.approveButton.addEventListener("click", SimulationModule.approve);
    elements.denyButton.addEventListener("click", SimulationModule.deny);
    elements.thresholdInput.addEventListener("change", SimulationModule.setThreshold);
    elements.dismissMockButton.addEventListener("click", () => {
      elements.slackMock.hidden = true;
    });

    UIModule.renderAudit(AuditModule.getEntries());
    UIModule.render(SimulationModule.getState());
  </script>
</body>
</html>

How does the safe mock work?

  • The top-level text value provides a fallback description of the alert.
  • The blocks array contains the header, action details, risk tier, fallback deadline, and mock buttons.
  • The password-type field masks whatever the learner types. NotificationModule.raise() checks only whether the field contains a value.
  • The payload is converted to formatted text inside #jsonPreview. No network request is created.
  • Save index.html.
  • Refresh the control-room page in your browser.

You should see the Slack Interactive Webhook Mock panel beneath the policy panel. The masked input should show the placeholder Not required for this project.

Missing the escalation panel?

Confirm that the Slack panel sits inside the left-hand .stack container. Check that #webhookInput and #slackMock each appear once.

If the page is blank, check the browser console for a syntax error near NotificationModule. Help me find the mismatch in my Slack mock implementation.

Trigger the local escalation

A high-risk proposal now performs two actions at the same moment. It opens the dashboard review controls. It also renders the local Slack-style alert.

Before you start the simulation, do you expect the low-risk lemon purchase or the high-risk robotic juicer to generate the mock alert?

  • Click Reset Run if a previous simulation has completed.
  • Click Start Simulation.
  • Keep the browser tab visible while the timed decisions run.
  • Wait for Buy Lemons to finish its automated review.
  • Watch the next proposal pause on Purchase Robotic Juicer.

You should see the 10-second review controls plus a local popup titled High-risk action needs review. The JSON preview should contain the top-level text and blocks fields.

Why keep this escalation local?

A Slack Incoming Webhooks URL contains a secret. Shipping that value inside browser code would expose it to anyone who can inspect the page.

Interactive Slack controls also send their interaction payload to a configured Request URL. This local dashboard has no backend endpoint for that lifecycle. The mock demonstrates the message design while the dashboard remains the only decision surface.

  • Take a screenshot of the high-risk review panel plus the local JSON preview.
  • Click Deny in the dashboard decision panel.

The simulation records a Denied audit row with Human as the approver. The local payload buttons remain preview data only.

Verify the webhook value is temporary

The masked field models safe secret handling by limiting the value to the current page memory. The application checks whether text exists without adding that text to the payload, console, audit record, or browser storage.

  • Let the current simulation finish.
  • Click Reset Run.
  • Enter Not required for this project in the Slack Webhook URL (optional demo input) field.
  • Click Start Simulation.
  • Wait for Purchase Robotic Juicer to pause the simulation.

The popup should say that a URL was detected without showing the value. The JSON preview should contain only the action details plus the mock approval structure.

Before you refresh, do you expect the masked field value or the audit records to survive?

  • Refresh the browser page.
  • Confirm that the masked webhook input is empty.
  • Confirm that the existing audit rows still appear in the audit table.

That is the safety boundary working. The webhook field disappears because it was never persisted. The audit evidence remains because LocalStorage contains only the decision records under agent-governance-audit-v1.

Secret mission

Stress-Test the Policy Boundary

A policy boundary earns trust when its behavior stays predictable as the threshold changes. Set the threshold to $10 and use the audit log to prove how every proposal was routed.

Clean Up Your Resources

Clean Up Your Resources

Your control room runs entirely on your Mac. This project costs $0 because it uses no cloud services or external API calls.

Resources you used:

  • A Python 3 HTTP server running from the agent-governance-control-room folder.
  • The agent-governance-control-room folder containing index.html in VS Code.
  • Browser LocalStorage audit records saved under agent-governance-audit-v1 for http://localhost:8000.

Keep everything running

No action needed. Choose this if you are still actively testing the control room.

  • Keep the Python HTTP server running while you use the dashboard.
  • Keep the agent-governance-control-room folder open in VS Code.
  • Keep the stored audit records so your previous decisions remain visible after a refresh.

Pause - I'll come back to this later

Shut down the running server process to free up local resources. Your completed file and audit records remain available for your next session.

  • Confirm the status pill reads System Idle or Simulation Complete.
  • Switch back to the integrated terminal in VS Code.
  • Press Control-C to stop the Python HTTP server.

The terminal returns to a prompt. Your index.html file and LocalStorage audit records remain intact.

Delete - I don't want to use this again

Remove all project resources to start fresh. Deletion is permanent, so your dashboard and audit history cannot be recovered from these local copies.

  • Use your browser settings to remove the stored site data for http://localhost:8000.
  • Refresh the browser tab to confirm the audit panel shows 0 events.
  • Switch back to the integrated terminal in VS Code.
  • Press Control-C to stop the Python HTTP server.
  • Close the agent-governance-control-room folder in VS Code.
  • Use Finder to locate the agent-governance-control-room folder.
  • Move the agent-governance-control-room folder to the Trash.
  • Confirm the folder containing index.html no longer appears in its previous location.

Nice Work!

Nice Work!

You did it! You built a single-file Agent Governance Control Room that keeps a simulated lemonade-sales agent inside clear spending boundaries.

You've learned how to:

  • Build a dark-mode control room that reveals how an ungoverned agent can overspend. Add deterministic policy gates that auto-approve low-risk actions. Pause high-risk actions for human review.
  • Implement a 10-second Default-Deny Fallback for unanswered decisions. Preserve append-only audit evidence across browser refreshes with LocalStorage.
  • Generate a safe Slack Block Kit-style escalation mock with a JSON payload preview. Protect the optional webhook secret from transmission or persistence.
  • Secret Mission: Stress-test the policy boundary at $10. Verify that actions below $10 become Low. Confirm that the $12 catering booth remains High. Prove that the audit trail captures the risk tier used for each decision.

Ready to quiz yourself?