Tools Manager
Example apps / toolsmanager / v0.1.0
Overlay appadmin permissionfront permissionneeds rednode
Every tool an AI assistant can see through REDnode: REDnode's own and each mod's, what each does, whether it asks first, and a switch to take any of them away from every assistant.
The tools, assistants and calls on this page are made up.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tools Manager</title>
<script src="rednode://ui/rednode-ui.js"></script>
<style>
html, body { margin: 0; width: 960px; height: 600px; overflow: hidden; background: transparent; }
rn-window { position: absolute; inset: 0; }
[hidden] { display: none !important; }
.status { font: 11px var(--rn-mono); letter-spacing: .08em; color: var(--rn-dim); }
.bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font: 11px var(--rn-mono); color: var(--rn-dim); }
.bar b { color: var(--rn-fg); font-weight: 600; }
.bar .off { color: var(--rn-danger); }
.spacer { flex: 1; }
.bar rn-input { width: 220px; }
rn-toggle { width: auto; }
rn-toggle::part(button) { gap: 8px; }
.main { flex: 1; min-height: 0; display: flex; gap: 12px; }
.list { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; border: 1px solid var(--rn-line); background: var(--rn-panel); }
.group { display: flex; align-items: center; gap: 8px; position: sticky; top: 0; z-index: 1; padding: 6px 8px;
background: var(--rn-panel-2); border-bottom: 1px solid var(--rn-line); font: 600 11px var(--rn-mono); letter-spacing: .1em; text-transform: uppercase; }
.group .count { color: var(--rn-dim); font-weight: 400; }
.tool { display: grid; grid-template-columns: 34px minmax(0, 210px) 64px minmax(0, 1fr); gap: 8px; align-items: baseline;
padding: 5px 8px; border-bottom: 1px solid color-mix(in srgb, var(--rn-line) 60%, transparent); cursor: pointer; font-size: 13px; }
.tool:hover { background: color-mix(in srgb, var(--rn-accent-2) 6%, transparent); }
.tool.selected { background: color-mix(in srgb, var(--rn-accent) 12%, transparent); }
.tool.off .tname, .tool.off .what { color: var(--rn-dim); text-decoration: line-through; }
.tname { font: 12px var(--rn-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--rn-fg); }
.mode { font: 700 9px var(--rn-mono); letter-spacing: .1em; }
.mode.reads { color: var(--rn-ok); }
.mode.asks { color: var(--rn-accent-2); }
.sw { width: 30px; height: 16px; padding: 0; border: 1px solid var(--rn-line); background: var(--rn-panel-2); cursor: pointer; position: relative; }
.sw::after { content: ''; position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; background: var(--rn-dim); transition: left .12s; }
.sw.on { border-color: color-mix(in srgb, var(--rn-accent) 60%, transparent); }
.sw.on::after { left: 16px; background: var(--rn-accent); }
.empty { padding: 16px; color: var(--rn-dim); }
.side { width: 300px; flex: none; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.box { border: 1px solid var(--rn-line); background: var(--rn-panel); padding: 8px 10px; overflow-y: auto; overflow-x: hidden; }
.box h3 { margin: 0 0 6px; font: 600 10px var(--rn-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--rn-dim); }
.details { flex: 1; min-height: 120px; font-size: 13px; overflow-wrap: anywhere; }
.details b { display: block; font: 700 14px var(--rn-display); letter-spacing: .04em; color: var(--rn-accent); margin-bottom: 4px; }
.details .mono { font: 11px var(--rn-mono); color: var(--rn-dim); }
.details .param { margin-top: 4px; }
.details .param code { font: 12px var(--rn-mono); color: var(--rn-fg); }
.details .warn { color: var(--rn-danger); margin-top: 6px; }
.who, .calls { font: 11px/1.5 var(--rn-mono); color: var(--rn-dim); }
.who b { color: var(--rn-fg); font-weight: 600; }
.calls { max-height: 150px; }
.calls .bad { color: var(--rn-danger); }
</style>
</head>
<body>
<rn-window title="Tools Manager" draggable closable>
<span class="status" slot="status" id="count">0 TOOLS</span>
<div class="bar">
<span id="server"></span>
<span class="spacer"></span>
<rn-input id="search" placeholder="Search tools"></rn-input>
<rn-toggle id="newoff" label="Off for new packages" title="On: a package installed from now on starts with its tools switched off, until you switch them on here"></rn-toggle>
</div>
<div class="main">
<div class="list" id="list"></div>
<div class="side">
<div class="box details" id="details">Select a tool to see what an assistant sees.</div>
<div class="box"><h3>Assistants</h3><div class="who" id="who">None yet.</div></div>
<div class="box calls" id="callsbox"><h3>Recent calls of mods' tools</h3><div id="calls">None yet.</div></div>
</div>
</div>
</rn-window>
<script src="app.js"></script>
</body>
</html>
// The Tools Manager (SV3, docs/services.md): every tool an AI assistant sees through
// REDnode's MCP server, grouped by where it comes from: REDnode's own, then each mod's
// @tool functions. A switch per tool and per group takes it away from every assistant at
// once; the switches are kept across starts. Also: which assistants have connected, and
// their recent calls of mods' tools.
// Part of the Admin Tools pack. Uses window.rednode.tools, which the host answers only
// for a package the player allowed the "admin" permission; until then the details pane
// says so.
'use strict';
const $ = (id) => document.getElementById(id);
let data = null;
let current = null; // the selected tool's key
let search = '';
function cleanError(err) {
return String(err?.message || err).replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
}
function el(tag, className, text) {
const e = document.createElement(tag);
if (className) e.className = className;
if (text !== undefined) e.textContent = text;
return e;
}
function ago(ms) {
const s = Math.round(ms / 1000);
if (s < 60) return `${s} s ago`;
if (s < 3600) return `${Math.round(s / 60)} min ago`;
return `${Math.round(s / 3600)} h ago`;
}
// REDnode's own tools that take the game API away from every assistant when off.
const KEY_TOOLS = new Set(['rednode:api_read', 'rednode:api_call']);
async function set(what, id, on) {
try {
data = await window.rednode.tools.set(what, id, on);
render();
} catch (err) {
RednodeUI.notify(cleanError(err), { type: 'error', title: 'Tools Manager' });
}
}
function sw(on, onClick, title) {
const b = el('button', `sw${on ? ' on' : ''}`);
b.title = title;
b.setAttribute('role', 'switch');
b.setAttribute('aria-checked', String(on));
b.addEventListener('click', (e) => {
e.stopPropagation();
onClick(!on);
});
return b;
}
function matches(t) {
if (!search) return true;
const q = search.toLowerCase();
return `${t.name} ${t.title} ${t.description}`.toLowerCase().includes(q);
}
function render() {
if (!data) return;
const list = $('list');
list.replaceChildren();
let total = 0;
let on = 0;
for (const g of data.groups) {
const tools = g.tools.filter(matches);
total += g.tools.length;
on += g.tools.filter((t) => !t.off && !g.off).length;
if (!tools.length) continue;
const head = el('div', 'group');
const allOn = !g.off && g.tools.every((t) => !t.off);
head.append(sw(allOn, (v) => set('group', g.id, v), allOn ? `Switch every ${g.name} tool off` : `Switch every ${g.name} tool on`));
head.append(el('span', '', g.name));
head.append(el('span', 'count', `${g.tools.length} tool${g.tools.length === 1 ? '' : 's'}${g.off ? ' · off' : ''}`));
list.append(head);
for (const t of tools) {
const off = t.off || g.off;
const row = el('div', `tool${off ? ' off' : ''}${t.key === current ? ' selected' : ''}`);
row.append(sw(!off, (v) => set('tool', t.key, v), off ? 'Switched off: no assistant sees it' : 'On: assistants see it'));
row.append(el('span', 'tname', t.name));
row.append(el('span', `mode ${t.readonly ? 'reads' : t.asks ? 'asks' : ''}`, t.readonly ? 'READS' : t.asks ? 'ASKS FIRST' : 'ACTS'));
row.append(el('span', 'what', t.description.split('\n')[0]));
row.addEventListener('click', () => {
current = t.key;
render();
});
list.append(row);
}
}
if (!list.children.length) list.append(el('div', 'empty', search ? 'No tool matches.' : 'No tools.'));
$('count').textContent = `${on} OF ${total} TOOLS ON`;
const server = $('server');
server.replaceChildren();
if (data.mcp.enabled) server.append('MCP server ', el('b', '', `on, port ${data.mcp.port}`), data.mcp.external ? '' : ' (packages only: the desktop switch is off in the launcher)');
else server.append('MCP server ', el('b', 'off', 'off'), ': switch it on in the launcher\'s settings');
$('newoff').toggleAttribute('checked', !!data.newOff);
renderDetails();
const who = $('who');
who.replaceChildren();
if (!data.assistants.length) who.append('None yet.');
for (const a of data.assistants) {
const line = el('div');
line.append(el('b', '', a.name), ` · ${a.kind === 'package' ? 'a package' : 'desktop'} · last call ${ago(a.lastSeenMs)}`);
who.append(line);
}
const calls = $('calls');
calls.replaceChildren();
if (!data.recent.length) calls.append('None yet.');
for (const c of data.recent) {
const line = el('div', c.ok ? '' : 'bad');
line.textContent = `${new Date(c.at).toLocaleTimeString()} ${c.assistant}: ${c.tool} ${c.ok ? '✓' : '✕'} ${c.ms} ms${c.detail ? ` · ${c.detail}` : ''}`;
calls.append(line);
}
}
function renderDetails() {
const box = $('details');
box.replaceChildren();
const t = data.groups.flatMap((g) => g.tools.map((x) => ({ ...x, group: g }))).find((x) => x.key === current);
if (!t) {
box.append('Select a tool to see what an assistant sees.');
return;
}
box.append(el('b', '', t.name));
box.append(el('div', 'mono', `${t.group.name} · ${t.readonly ? 'only reads: runs without asking' : t.asks ? 'can change the game: the player is asked before every call' : 'acts without asking (it changes REDnode, not the game)'}`));
box.append(el('div', '', t.description || 'No description.'));
const params = t.params ?? Object.entries(t.input?.properties ?? {}).map(([name, s]) => ({ name, type: s.type ?? (s.enum ? s.enum.join(' | ') : 'any'), optional: !(t.input.required ?? []).includes(name), description: s.description ?? '' }));
if (params.length) {
for (const p of params) {
const line = el('div', 'param');
line.append(el('code', '', `${p.name}${p.optional ? '?' : ''}: ${p.type}`));
if (p.description) line.append(` - ${p.description}`);
box.append(line);
}
} else box.append(el('div', 'mono', 'No parameters.'));
if (KEY_TOOLS.has(t.key)) box.append(el('div', 'warn', 'Off, this takes the game API away from every assistant, REDagent included.'));
}
async function refresh() {
try {
data = await window.rednode.tools.list();
render();
} catch (err) {
$('details').textContent = cleanError(err);
}
}
$('newoff').addEventListener('change', (e) => set('new-off', null, e.detail.checked));
$('search').addEventListener('input', (e) => {
search = $('search').value ?? '';
render();
});
refresh();
setInterval(refresh, 2000);
REDnode runs JavaScript. The rednode package also has TypeScript types, so an editor checks these calls; compile TypeScript to .mjs first.
{
"id": "toolsmanager",
"name": "Tools Manager",
"version": "0.1.0",
"author": "Edgemodders",
"description": "Every tool an AI assistant can see through REDnode's MCP server: REDnode's own and each mod's, with what each does and whether it asks first. Switch any tool or mod off for every assistant; see which assistants are connected and their recent calls.",
"entry": "index.html",
"permissions": ["admin", "front"],
"requires": { "rednode": ">=0.37.0" },
"kind": "window",
"width": 960,
"height": 600,
"anchor": "center",
"x": 0,
"y": 0,
"open": false
}
The manifest names the package and says how REDnode runs it. Its fields are in Overlay apps.
How it works
The list is the same one REDnode's MCP server builds for Claude Code and REDagent: REDnode's tools, then each running mod's @tool functions from its service file.
A switch per tool and per package is kept in REDnode's settings across starts. A tool switched off leaves every assistant's list at its next request, and mod_call refuses it. Off for new packages makes a newly installed mod's tools start switched off.
The switches stay in REDnode, so removing the pack keeps them as they are. To change them again, install the pack again.
Like the Task Manager it needs the admin permission; the same calls are admin.tools() and admin.setTool(...) in the rednode package.
Part of REDnode Admin Tools.
What it uses
"admin" permission window.rednode.tools admin.tools admin.setTool
Try this
- Select counter_service__add: its parameter and that it asks first.
- Switch counter_service__reset off, then the whole Counter service.
- Look at Recent calls.
Use it
Copy the files into red4ext/plugins/REDnode/apps/toolsmanager/ in the game folder, or start your own package from them: change id and name in the manifest first. The whole folder is on GitHub.