openvibers/openvibe.bot Commits

Bot: a docs page for routines, webhooks (with how to check a delivery) and the agent switch (#96)

OP OpenVibers committed 14 Oct 2026 · 87fd64e24b · 4 files, +87 −6

Plan T15 B11 (docs current). Routines, webhooks and the agent switch shipped today (Bot#93, #94), but the public build docs covered only drivers and profiles. A webhook receiver had nowhere to learn how to check a delivery's signature.

**New page `/docs/automation`: "Routines, webhooks and your agent"** (`renderAutomationPage` in `server/web/docs.js`)
- **Steps**: every step word, held steps and the deadman, a refusal stopping the rest.
- **Routines**: the four triggers, running as the owner, one at a time, the limits, and the API.
- **Webhooks**: the five events, the per-hook `whsec_` secret, the body, the Events-style signature, public addresses with no redirects, the backoff, and an Express sample that checks `verifyDeliveryV2` from `openvibe-sdk/events` before trusting a delivery.
- **Your agent**: the switch and what the agent may do (as the owner, never anyone else), a `curl` for `POST /api/v1/robots/:id/steps`, the 20-step and 30-second bounds, and `bot.agent_not_allowed`.
- It is in the build nav, linked from `/docs`, in the sitemap, and in `/llms.txt` and `/llms-full.txt` (with no API paths, as that file's rule asks).

**Tests**
- `docs-pages`: the page's sections and sample, the nav, the index link and the Frame.
- `llms`: the new page's title and URL.
- `docs`, `home` and `legal` pass.

Changes

ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex 5cb9f46..b53e2d5 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -11,6 +11,7 @@
* renderDocsIndex({ config }) GET /docs
* renderDriversPage({ config }) GET /docs/drivers
* renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate
++ * renderAutomationPage({ config }) GET /docs/automation (routines, webhooks, the agent switch)
* formatProblems(problems) / validationResult(result)
*/
const { esc } = require('./render');
@@@ -29,7 +30,7 @@ const out = (href, text) => `<a href="${esc(href)}" rel="noopener">${esc(text)}<
/** A link on this site. */
const inLink = (href, text) => `<a href="${esc(href)}">${esc(text)}</a>`;
--const PAGES = [['/docs', 'Overview'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles']];
++const PAGES = [['/docs', 'Overview'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles'], ['/docs/automation', 'Routines and webhooks']];
/** The Build pages inside the OpenVibe Frame: a sidebar of the three pages beside the content, plain markup. */
function docsPage({ config, path, title, description, content }) {
@@@ -59,6 +60,8 @@ function renderDocsIndex({ config }) {
<section class="card"><h2>Robot profiles</h2>
<p>A profile (<code>bot.robot-profile@1</code>) says what the robot can do, which commands its driver accepts, and which widgets the panel shows. The panel is a function of the profile: a new robot needs a profile, not code.</p>
<p>${inLink('/docs/profiles', 'Robot profiles')} lists the fields, shows a full example, and has a validator for a profile you paste.</p></section>
++<section class="card"><h2>Automate a robot</h2>
++<p>${inLink('/docs/automation', 'Routines, webhooks and your agent')}: steps on a schedule, signed event deliveries to your server, and letting your OpenVibe.Actor agent drive.</p></section>
<section class="card"><h2>Robots</h2>
<p>Add a robot and drive it from any browser. The <b>Simulated rover</b> runs inside Bot with no hardware, so you can see your profile's panel before anything is wired up.</p>
<p>${inLink('/robots', 'Your robots')} · ${inLink('/install', 'Install a device')}</p></section>
@@@ -281,4 +284,62 @@ ${validationResult(result)}
return docsPage({ config, path: '/docs/profiles', title: 'Robot profiles', description: 'The bot.robot-profile@1 format that turns a driver\'s capabilities into a panel, a full example, and a validator for your own profile.', content });
}
--module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, validationResult };
++// ── GET /docs/automation ──────────────────────────────────────────────────────────────────────────
++
++const WEBHOOK_VERIFY = `const { verifyDeliveryV2 } = require('openvibe-sdk/events');
++
++// Express, with the raw body: the signature covers the exact bytes Bot sent.
++app.post('/robot-events', express.text({ type: 'application/json' }), (req, res) => {
++ if (!verifyDeliveryV2(req.body, req.headers, process.env.ROBOT_WEBHOOK_SECRET)) return res.status(401).end();
++ const event = JSON.parse(req.body); // { id, type, robot_id, occurred_at, data }
++ if (event.type === 'bot.estop.set') console.log('robot', event.robot_id, 'stopped');
++ res.status(204).end();
++});`;
++
++const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/steps \\
++ -H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\
++ -d '{"steps": "forward 1\\nleft 0.5\\nheadlights #00ff00\\nstop"}'
++# → {"robot_id": "rob_…", "result": "done", "steps": 4}`;
++
++function renderAutomationPage({ config }) {
++ const content = `
++<h1>Routines, webhooks and your agent</h1>
++<p class="muted">A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits, cooldowns and e-stop apply, and the audit records each command.</p>
++
++<section class="card" aria-labelledby="steps-h"><h2 id="steps-h">Steps</h2>
++<p>Routines and the agent both speak in steps, one per line, checked against the robot's own profile. A robot takes only what it has:</p>
++<ul>
++<li><code>forward 2</code>, <code>back 1 0.4</code>, <code>left 0.5</code>, <code>right 0.5</code>: drive for that many seconds, at an optional speed (0–1); <code>stop</code>.</li>
++<li><code>wait 1</code>: pause.</li>
++<li><code>say Hello</code>, <code>face happy</code>, <code>show Hi</code>: speech, a face or text on its display.</li>
++<li><code>headlights #ff8800</code>, <code>horn A4 0.3</code>, <code>lights off</code>, <code>pan 0.5 1</code>: an actuator or a camera axis by its name in the profile, with a value and how long to hold it.</li>
++<li><code>press horn</code>: one of its buttons.</li>
++</ul>
++<p>A held step (a motion or a tone) is re-sent inside the device's deadman window and released at its end. A refused step (the e-stop, the robot going offline, a cooldown) stops the rest, with the reason.</p></section>
++
++<section class="card" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2>
++<p>On a robot's panel, under <b>Routines</b>, the owner names a routine, writes its steps and picks when it runs:</p>
++<ul>
++<li>when the owner presses Run;</li>
++<li>every N minutes (at least 5);</li>
++<li>daily at a UTC time, on chosen weekdays;</li>
++<li>each time the robot comes online.</li>
++</ul>
++<p>A routine runs as the owner. One routine runs on a robot at a time; removing a running routine stops it. Limits: 60 steps, 60 seconds a step, 10 minutes in all. The same list is <code>GET|POST /api/v1/robots/:id/routines</code>, <code>POST …/routines/:rid/run</code> and <code>DELETE …/routines/:rid</code>, for the owner or a service with <code>bot.robot.manage</code>.</p></section>
++
++<section class="card" aria-labelledby="hooks-h"><h2 id="hooks-h">Webhooks</h2>
++<p>Under <b>Webhooks</b>, the owner adds up to five https addresses, each for some of the robot's events: <code>bot.robot.online</code>, <code>bot.robot.offline</code>, <code>bot.estop.set</code>, <code>bot.estop.cleared</code>, <code>bot.command.refused</code>. Each hook has its own secret (<code>whsec_…</code>), shown once when it is made.</p>
++<p>Bot posts <code>{ id, type, robot_id, occurred_at, data }</code>, signed the way OpenVibe.Events signs a delivery (<code>X-OpenVibe-Timestamp</code>, <code>X-OpenVibe-Signature-V2</code>), only to public addresses, never following a redirect. Anything but a 2xx is retried after 30 seconds, then 2, 10, 30 and 120 minutes. Check the signature before trusting a delivery:</p>
++${code(WEBHOOK_VERIFY)}
++<p class="muted">The API is <code>GET|POST /api/v1/robots/:id/webhooks</code> (the POST answers the secret) and <code>DELETE …/webhooks/:hid</code>.</p></section>
++
++<section class="card" aria-labelledby="agent-h"><h2 id="agent-h">Your agent</h2>
++<p>On the panel, <b>Your agent</b> has one switch: <b>Let my OpenVibe.Actor agent drive this robot</b>. It is off until the owner turns it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "drive my rover forward for a second and turn its lights green", can see your robots (online, e-stop, battery, sensors) and run steps on this one, as you. It never acts for anyone else, and turning the switch off stops it at once.</p>
++<p>Your own token runs steps directly, and answers when they end:</p>
++${code(STEPS_CALL)}
++<p class="muted"><code>GET /api/v1/robots/:id/steps</code> answers the robot's state and the step words it takes. A call takes at most 20 steps and 30 seconds; the agent's calls are refused with <code>403 bot.agent_not_allowed</code> while the switch is off.</p></section>
++`;
++ return docsPage({ config, path: '/docs/automation', title: 'Routines, webhooks and your agent', description: 'Make a robot run steps on a schedule, send its events to your server signed, and let your OpenVibe.Actor agent drive it, all through the same gate.', content });
++}
++
++module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, validationResult };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 04dda6c..e2182a1 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -37,7 +37,7 @@ const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage } = require('./render');
--const { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');
++const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
const { renderDirectory, renderModels, renderModel, renderRobotPage, renderAddRobot, renderSubmit, renderSubmissions, modelOf } = require('./directory');
@@@ -51,7 +51,7 @@ const TRY_PROFILE = loadProfiles().get('sim.rover');
const SITE_TAGLINE = 'An open control panel for robots';
// Every public GET page, for the sitemap. The interactive pages (/robots, /pair/, /panel/) need a session and
// are Disallowed in robots.txt; the API and sign-in are not pages.
--const PUBLIC_PAGES = ['/', '/try', '/robots', '/models', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];
++const PUBLIC_PAGES = ['/', '/try', '/robots', '/models', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/docs/automation', '/terms', '/privacy', '/dmca'];
// The public pages an AI crawler can read, for /llms.txt and /llms-full.txt (llmstxt.org). Same pages as the
// sitemap minus /install (a 302 to the installer script, not a page); no signed-in or per-robot page, nothing
// under the API or /internal. `note` is the one-line llms.txt entry, `text` the page's full text in llms-full.txt,
@@@ -95,6 +95,11 @@ const LLMS_PAGES = [
note: 'The bot.robot-profile@1 format, a full example and a validator.',
text: 'The robot profile format (bot.robot-profile@1) that OpenVibe.Bot renders a panel from. The page lists the fields (connection kind, motion kinds, commands, widgets and limits), shows a full example, and has a validator for a profile you paste. Profiles ship with Bot, including the simulated rover (sim.rover).',
},
++ {
++ path: '/docs/automation', title: 'Routines, webhooks and your agent',
++ note: 'Steps on a schedule, signed webhooks for a robot\'s events, and the switch that lets your OpenVibe.Actor agent drive.',
++ text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, e-stop, audit). Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline, bot.estop.set, bot.estop.cleared and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',
++ },
{
path: '/terms', title: 'Terms of Service',
note: 'The terms for using OpenVibe.Bot.',
@@@ -250,6 +255,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.get('/docs', frameCsp, (req, res) => res.type('html').send(renderDocsIndex({ config })));
r.get('/docs/drivers', frameCsp, (req, res) => res.type('html').send(renderDriversPage({ config })));
r.get('/docs/profiles', frameCsp, (req, res) => res.type('html').send(renderProfilesPage({ config })));
++ r.get('/docs/automation', frameCsp, (req, res) => res.type('html').send(renderAutomationPage({ config })));
r.post('/docs/profiles/validate', frameCsp, ...validateLimit, express.urlencoded({ extended: false, limit: '32kb' }), (req, res, next) => {
try {
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('cross-site form posts are refused\n');
ddiff --git a/test/docs-pages.test.js b/test/docs-pages.test.js
iindex b1b96b7..bbf9380 100644
---- a/test/docs-pages.test.js
++++ b/test/docs-pages.test.js
@@@ -76,6 +76,20 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');
assert.ok(html.includes('id="navbar-mount"'), 'inside the OpenVibe Frame');
});
++ await check('GET /docs/automation: steps, routines, signed webhooks with a verification sample, and the agent switch', async () => {
++ const r = await get('/docs/automation');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('content-security-policy'), SITE_CSP);
++ const html = await r.text();
++ assert.match(html, /<h1>Routines, webhooks and your agent<\/h1>/);
++ for (const word of ['forward 2', 'every N minutes', 'bot.estop.set', 'whsec_', 'X-OpenVibe-Signature-V2', 'verifyDeliveryV2', 'Let my OpenVibe.Actor agent drive this robot', '/api/v1/robots/rob_…/steps', 'bot.agent_not_allowed']) {
++ assert.ok(html.includes(word), word);
++ }
++ assert.ok(html.includes('href="/docs/automation" aria-current="page"'), 'in the build nav');
++ assert.match(await (await get('/docs')).text(), /href="\/docs\/automation"/, 'the index links it');
++ assert.ok(html.includes('id="navbar-mount"'), 'inside the OpenVibe Frame');
++ });
++
await check('POST /docs/profiles/validate: the shipped sim.rover profile is valid', async () => {
const r = await form(JSON.stringify(sim));
assert.strictEqual(r.status, 200);
ddiff --git a/test/llms.test.js b/test/llms.test.js
iindex b69dcb1..f0db3ae 100644
---- a/test/llms.test.js
++++ b/test/llms.test.js
@@@ -7,9 +7,9 @@ const { boot, check, done } = require('./helpers/app');
// The public pages, in the order the files list them; BASE_URL is fixed by the test harness.
const BASE = 'http://bot.test';
--const PATHS = ['/', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];
++const PATHS = ['/', '/docs', '/docs/drivers', '/docs/profiles', '/docs/automation', '/terms', '/privacy', '/dmca'];
const TITLES = ['OpenVibe.Bot — drive your robot from any browser', 'Build for OpenVibe.Bot', 'Write a driver',
-- 'Robot profiles', 'Terms of Service', 'Privacy Policy', 'DMCA & Copyright'];
++ 'Robot profiles', 'Routines, webhooks and your agent', 'Terms of Service', 'Privacy Policy', 'DMCA & Copyright'];
(async () => {
const t = await boot({ openre: false });