Documentation

User guide and developer references.

Developer Guide

Full examples

Run a virtual toy over HTTP, or track tasks with Task Ledger. Both examples include the complete extension source.

Virtual Webhook Toy

This extension gives the AI tools to set a level, play a timed pattern, and stop a simulated toy. The chat panel shows the request, the server response, and an event log. No hardware is required. The server records pattern names and durations without generating a vibration waveform.

Run the toy

  1. Download all four files below into devextensions/virtual-webhook-toy/. Keep the filenames shown. Install Node.js 18 or later if you do not have it. The server has no package dependencies.
  2. From the folder containing devextensions, start the server with the command below. It prints listening on http://localhost:4400.
sh
node devextensions/virtual-webhook-toy/virtual-toy-server.mjs
  1. In the app, open Settings → Advanced. Turn on Developer Mode and Show developer features. In Dev Extensions, select the parent devextensions folder, scan, and install virtual-webhook-toy. Unload any other dev extension first.
  2. Open a chat and show the extension panel. Expand Virtual Webhook Toy and click Ping. The status changes to Idle when the server responds.
  3. Ask your persona: "Use toy_set_level to set the virtual toy to level 4." With a model that supports tool calls, the chat shows "Adjusted the toy" and the panel shows level 4 with the JSON response.
  4. Ask for a pulse at level 5 for 5 seconds. The panel shows the pattern, then returns to Idle within two seconds of completion while the panel is open. Start it again and try the panel Stop button and the top-bar Emergency Stop.

The default Maximum level is 7. Ask for level 10 to see the extension clamp the command to 7 and report the limit in its tool result. Stop the server and try another command to see the failed request in the panel and chat. Restart the server and click Ping to reconnect.

ℹ
Run the server on the same computer as the app. Requests come from the app, not the AgenticLover backend. A browser may ask for local network access. If it blocks the request, check its console and permissions. Dev extensions stay enabled for every persona while installed. Unload this example when you finish.

Set TOY_PORT to use another port and update Toy URL in the extension settings. Set TOY_TOKEN to require a bearer token and enter the same value in Bearer token. The README includes the HTTP contract and troubleshooting steps.

Download the source

These are the files from the runnable example in the repository. Download them or expand a file to read and copy its source. The JavaScript module is self-contained and needs no build step.

Download manifest.json
manifest.json
json
{
  "apiVersion": 7,
  "version": "1.0.0",
  "key": "virtual-webhook-toy",
  "name": "Virtual Webhook Toy",
  "category": "toy",
  "icon": "Radio",
  "description": "A simulated toy driven over HTTP. The AI sets levels and plays patterns; you watch the result in the chat panel.",
  "supportsPromptContributions": true,
  "supportsLifecycleHooks": true,
  "supportsEmergencyStop": true,
  "chatPanel": { "order": 150 },
  "settings": {
    "fields": {
      "webhookUrl": {
        "id": "webhookUrl",
        "type": "text",
        "scope": "global",
        "title": "Toy URL",
        "description": "Base URL of the virtual toy server. Run virtual-toy-server.mjs to start one locally.",
        "default": "http://localhost:4400",
        "placeholder": "http://localhost:4400",
        "allowPersonaOverride": false
      },
      "authToken": {
        "id": "authToken",
        "type": "password",
        "scope": "global",
        "title": "Bearer token",
        "description": "Only needed when the server was started with TOY_TOKEN.",
        "secret": true,
        "allowPersonaOverride": false
      },
      "toyName": {
        "id": "toyName",
        "type": "text",
        "scope": "global",
        "title": "Toy name",
        "description": "How the persona refers to the toy.",
        "default": "Virtual toy"
      },
      "maxLevel": {
        "id": "maxLevel",
        "type": "slider",
        "scope": "global",
        "title": "Maximum level",
        "description": "The AI cannot go above this. Personas can lower it.",
        "min": 1,
        "max": 10,
        "step": 1,
        "default": 7
      }
    },
    "mainLayout": ["webhookUrl", "authToken", "toyName", "maxLevel"],
    "quickLayout": ["maxLevel"]
  }
}
Download virtual-webhook-toy.js
virtual-webhook-toy.js
js
// Virtual Webhook Toy
//
// A toy extension whose "hardware" is an HTTP server (see virtual-toy-server.mjs).
// The AI drives it through three tools, the persona sees its live state in the
// prompt, and the user sees every command and response in the chat panel.

const React = globalThis.React;
const h = React.createElement;

const DEFAULT_URL = 'http://localhost:4400';
const PATTERNS = ['pulse', 'wave', 'ramp'];
const MAX_EVENTS = 8;
const PING_STALE_MS = 10_000;

// Module-level state. Hooks and tools write here; the hook and the panel read it.
// Settings are cached from lifecycle hooks because tool calls do not receive them.
const state = {
  settings: {},
  connected: false,
  checkedAt: 0,
  toy: { name: 'Virtual toy', power: false, level: 0, pattern: null, until: null },
  lastCommand: null,
  events: [],
};

// Components subscribe with useSyncExternalStore rather than a one-off effect:
// the host can evaluate this module more than once per session, and React only
// re-subscribes when the subscribe function's identity changes.
const listeners = new Set();
let version = 0;
function publish() {
  version += 1;
  listeners.forEach((fn) => fn());
}
function subscribe(fn) {
  listeners.add(fn);
  return () => listeners.delete(fn);
}
const getVersion = () => version;

// ---- Settings ------------------------------------------------------------

function cacheSettings(resolved) {
  if (resolved && typeof resolved === 'object') state.settings = resolved;
}

function baseUrl() {
  const raw = String(state.settings.webhookUrl || DEFAULT_URL).trim();
  return raw.replace(/\/+$/, '');
}

function maxLevel() {
  const n = Number(state.settings.maxLevel);
  return Number.isFinite(n) && n >= 1 ? Math.min(10, Math.round(n)) : 7;
}

function toyName() {
  return String(state.settings.toyName || '').trim() || 'Virtual toy';
}

// ---- HTTP ---------------------------------------------------------------

function headers() {
  const out = { 'Content-Type': 'application/json' };
  const token = String(state.settings.authToken || '').trim();
  if (token) out.Authorization = `Bearer ${token}`;
  return out;
}

async function request(path, body) {
  const response = await fetch(`${baseUrl()}${path}`, {
    method: body ? 'POST' : 'GET',
    headers: headers(),
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(5000),
  });
  const payload = await response.json().catch(() => ({}));
  if (!response.ok || payload.ok === false) {
    throw new Error(payload.error || `Toy server responded ${response.status}`);
  }
  return payload;
}

function applyToy(toy) {
  if (!toy || typeof toy !== 'object') return;
  state.toy = {
    name: toyName(),
    power: Boolean(toy.power),
    level: Number(toy.level) || 0,
    pattern: toy.pattern || null,
    until: toy.until || null,
  };
}

function pushEvent(text, ok) {
  state.events = [{ at: Date.now(), text, ok }, ...state.events].slice(0, MAX_EVENTS);
}

async function ping() {
  state.checkedAt = Date.now();
  try {
    const payload = await request('/state');
    applyToy(payload.toy);
    if (!state.connected) pushEvent(`Connected to ${baseUrl()}`, true);
    state.connected = true;
  } catch (err) {
    if (state.connected) pushEvent(`Lost ${baseUrl()}: ${err.message}`, false);
    state.connected = false;
  }
  publish();
  return state.connected;
}

async function sendCommand(action, body) {
  const requestBody = { action, ...body };
  const sentAt = Date.now();
  try {
    const payload = await request('/command', requestBody);
    applyToy(payload.toy);
    state.connected = true;
    state.lastCommand = { sentAt, request: requestBody, response: payload, ok: true };
    pushEvent(payload.message || action, true);
    publish();
    return payload;
  } catch (err) {
    state.connected = false;
    state.lastCommand = { sentAt, request: requestBody, response: null, ok: false, error: err.message };
    pushEvent(`${action} failed: ${err.message}`, false);
    publish();
    throw err;
  }
}

function remainingSeconds() {
  if (!state.toy.until) return 0;
  return Math.max(0, Math.round((new Date(state.toy.until).getTime() - Date.now()) / 1000));
}

function describeToy() {
  if (!state.connected) return `${toyName()}: Disconnected`;
  if (!state.toy.power) return `${toyName()}: Idle`;
  const parts = [`level ${state.toy.level}/10`];
  if (state.toy.pattern) parts.push(`${state.toy.pattern} pattern, ${remainingSeconds()}s left`);
  return `${toyName()}: On (${parts.join(', ')})`;
}

// ---- Manifest ------------------------------------------------------------

export const manifest = {
  apiVersion: 7,
  version: '1.0.0',
  key: 'virtual-webhook-toy',
  name: 'Virtual Webhook Toy',
  category: 'toy',
  icon: 'Radio',
  description: 'A simulated toy driven over HTTP. The AI sets levels and plays patterns; you watch the result in the chat panel.',
  supportsPromptContributions: true,
  supportsLifecycleHooks: true,
  supportsEmergencyStop: true,
  chatPanel: { order: 150 },
  settings: {
    fields: {
      webhookUrl: {
        id: 'webhookUrl',
        type: 'text',
        scope: 'global',
        title: 'Toy URL',
        description: 'Base URL of the virtual toy server. Run virtual-toy-server.mjs to start one locally.',
        default: DEFAULT_URL,
        placeholder: DEFAULT_URL,
        allowPersonaOverride: false,
      },
      authToken: {
        id: 'authToken',
        type: 'password',
        scope: 'global',
        title: 'Bearer token',
        description: 'Only needed when the server was started with TOY_TOKEN.',
        secret: true,
        allowPersonaOverride: false,
      },
      toyName: {
        id: 'toyName',
        type: 'text',
        scope: 'global',
        title: 'Toy name',
        description: 'How the persona refers to the toy.',
        default: 'Virtual toy',
      },
      maxLevel: {
        id: 'maxLevel',
        type: 'slider',
        scope: 'global',
        title: 'Maximum level',
        description: 'The AI cannot go above this. Personas can lower it.',
        min: 1,
        max: 10,
        step: 1,
        default: 7,
      },
    },
    mainLayout: ['webhookUrl', 'authToken', 'toyName', 'maxLevel'],
    quickLayout: ['maxLevel'],
  },
  PanelComponent: ToyPanel,
};

// ---- Extension state -----------------------------------------------------

export function useExtension() {
  React.useSyncExternalStore(subscribe, getVersion);

  const on = state.connected && state.toy.power;
  const parts = [{ label: 'Level', value: state.toy.level, unit: '/10' }];
  if (state.toy.pattern) {
    parts.push({ label: 'Pattern', value: state.toy.pattern });
    parts.push({ label: 'Remaining', value: remainingSeconds(), unit: 's' });
  } else if (on) {
    parts.push({ value: 'Continuous' });
  }

  return {
    isInstalled: true,
    isAvailable: true,
    isEnabled: true,
    isActive: on,
    statusIndicator: !state.connected
      ? { color: 'white', label: 'Disconnected', description: `No toy server at ${baseUrl()}` }
      : on
        ? { color: 'green', label: `On, level ${state.toy.level}` }
        : { color: 'yellow', label: 'Idle' },
    instances: [
      {
        id: 'virtual-toy',
        name: toyName(),
        toolName: 'toy_set_level',
        isConnected: state.connected,
        metadata: { url: baseUrl() },
      },
    ],
    promptState: {
      toys: [
        {
          kind: 'toy',
          id: 'virtual-toy',
          name: toyName(),
          visibility: 'visible',
          statusLabel: !state.connected ? 'Disconnected' : on ? 'On' : 'Idle',
          parts,
        },
      ],
    },
    globalSettings: {},
    personaSettings: {},
    resolvedSettings: {},
    enable: async () => {},
    disable: async () => {},
    updateGlobalSettings: async () => {},
    updatePersonaSettings: async () => {},
    updateSettings: async () => {},
  };
}

// ---- Prompt --------------------------------------------------------------

export function getPromptContributions(ctx) {
  cacheSettings(ctx.extensionState?.resolvedSettings);
  return [
    {
      placement: 'tool_guidance',
      priority: 20,
      text:
        `${toyName()} is a simulated toy you control. Use toy_set_level (0 to ${maxLevel()}) for steady output, `
        + `toy_play_pattern (${PATTERNS.join(', ')}) for a timed pattern at its own level, and toy_stop to switch it off. `
        + `One call is enough: toy_play_pattern already sets the level. `
        + `Tell the user what you are doing before you do it. Current state: ${describeToy()}.`,
    },
  ];
}

// ---- Lifecycle -----------------------------------------------------------

export async function onBeforeLLMRequest(ctx) {
  cacheSettings(ctx.extensionState?.resolvedSettings);
  if (!state.connected || Date.now() - state.checkedAt > PING_STALE_MS) {
    await ping();
  }
}

export async function onEmergencyToyStop(ctx) {
  cacheSettings(ctx.extensionState?.resolvedSettings);
  // Let the host report a failed stop instead of treating it as successful.
  await sendCommand('stop', { reason: `emergency stop (${ctx.source})` });
}

// ---- Tools ---------------------------------------------------------------

export function getTools() {
  return [
    {
      type: 'function',
      function: {
        name: 'toy_set_level',
        description: 'Set the toy to a steady level. 0 switches it off.',
        parameters: {
          type: 'object',
          properties: {
            level: { type: 'integer', minimum: 0, maximum: 10, description: 'Output level from 0 to 10.' },
          },
          required: ['level'],
        },
      },
      displayLabels: { pending: 'Adjusting the toy…', success: 'Adjusted the toy', error: "Couldn't reach the toy" },
    },
    {
      type: 'function',
      function: {
        name: 'toy_play_pattern',
        description: 'Play a timed pattern on the toy at the given peak level.',
        parameters: {
          type: 'object',
          properties: {
            pattern: { type: 'string', enum: PATTERNS, description: 'Which pattern to play.' },
            level: { type: 'integer', minimum: 1, maximum: 10, description: 'Peak level from 1 to 10.' },
            seconds: { type: 'integer', minimum: 5, maximum: 120, description: 'How long to play, 5 to 120 seconds.' },
          },
          required: ['pattern', 'level', 'seconds'],
        },
      },
      displayLabels: { pending: 'Starting a pattern…', success: 'Started a pattern', error: "Couldn't reach the toy" },
    },
    {
      type: 'function',
      function: {
        name: 'toy_stop',
        description: 'Switch the toy off immediately.',
        parameters: { type: 'object', properties: {} },
      },
      displayLabels: { pending: 'Stopping the toy…', success: 'Stopped the toy', error: "Couldn't reach the toy" },
    },
  ];
}

export async function executeTool(name, args, ctx) {
  const limit = maxLevel();

  if (name === 'toy_set_level') {
    const requested = Math.round(Number(args.level) || 0);
    const level = Math.max(0, Math.min(limit, requested));
    const payload = await sendCommand('set_level', { level });
    if (level > 0) ctx.runtime.ui.toast({ message: `${toyName()} set to level ${level}`, level: 'info' });
    return {
      ok: true,
      level,
      clamped: requested !== level ? `Requested ${requested}, limited to ${limit}.` : undefined,
      state: describeToy(),
      serverMessage: payload.message,
    };
  }

  if (name === 'toy_play_pattern') {
    const pattern = PATTERNS.includes(args.pattern) ? args.pattern : PATTERNS[0];
    const requested = Math.round(Number(args.level) || 1);
    const level = Math.max(1, Math.min(limit, requested));
    const seconds = Math.max(5, Math.min(120, Math.round(Number(args.seconds) || 30)));
    const payload = await sendCommand('play_pattern', { pattern, level, seconds });
    ctx.runtime.ui.toast({ message: `${toyName()}: ${pattern} for ${seconds}s at level ${level}`, level: 'info' });
    return {
      ok: true,
      pattern,
      level,
      seconds,
      clamped: requested !== level ? `Requested ${requested}, limited to ${limit}.` : undefined,
      state: describeToy(),
      serverMessage: payload.message,
    };
  }

  if (name === 'toy_stop') {
    const payload = await sendCommand('stop', {});
    return { ok: true, state: describeToy(), serverMessage: payload.message };
  }

  throw new Error(`Unknown tool: ${name}`);
}

// ---- Panel ---------------------------------------------------------------

function formatTime(ms) {
  return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}

function ToyPanel(props) {
  React.useSyncExternalStore(subscribe, getVersion);
  const [busy, setBusy] = React.useState(false);

  cacheSettings(props.extension?.resolvedSettings);

  React.useEffect(() => {
    void ping();
    // Poll so the panel follows the server when a pattern ends on its own.
    const id = setInterval(() => { void ping(); }, 2000);
    return () => clearInterval(id);
  }, [subscribe]);

  const stop = async () => {
    setBusy(true);
    try {
      await sendCommand('stop', { reason: 'panel' });
    } catch (err) {
      console.warn('[virtual-webhook-toy] stop failed', err);
    } finally {
      setBusy(false);
    }
  };

  const dot = state.connected
    ? (state.toy.power ? 'bg-eucalyptus-400' : 'bg-orchid-300')
    : 'bg-rose-400';
  const segments = Array.from({ length: 10 }, (_, i) =>
    h('div', {
      key: i,
      className: `h-2 flex-1 rounded-sm ${i < state.toy.level ? 'bg-orchid-400' : 'bg-bg-raised'}`,
    }),
  );

  return h('div', { className: 'space-y-3 text-sm' },
    h('div', { className: 'flex items-center gap-2' },
      h('span', { className: `h-2.5 w-2.5 rounded-full ${dot}` }),
      h('span', { className: 'font-medium text-text-primary' }, toyName()),
      h('span', { className: 'ml-auto font-mono text-xs text-text-muted' }, baseUrl()),
    ),
    h('div', { className: 'flex gap-1' }, ...segments),
    h('p', { className: 'text-text-secondary' }, describeToy()),
    h('div', { className: 'flex gap-2' },
      h('button', {
        className: 'rounded-md bg-rose-500/20 px-3 py-1 text-xs font-medium text-rose-200 hover:bg-rose-500/30 disabled:opacity-50',
        disabled: busy || !state.connected,
        onClick: stop,
      }, 'Stop'),
      h('button', {
        className: 'rounded-md bg-bg-raised px-3 py-1 text-xs text-text-secondary hover:text-text-primary',
        onClick: () => { void ping(); },
      }, 'Ping'),
      props.chat
        ? h('button', {
            className: 'rounded-md bg-bg-raised px-3 py-1 text-xs text-text-secondary hover:text-text-primary',
            onClick: () => props.chat.sendMessage('Turn the toy up a little.'),
          }, 'Ask for more')
        : null,
    ),
    state.lastCommand
      ? h('div', { className: 'rounded-lg border border-border bg-bg-raised p-2' },
          h('p', { className: 'mb-1 text-xs text-text-muted' },
            `Last command at ${formatTime(state.lastCommand.sentAt)}`),
          h('pre', { className: 'overflow-x-auto whitespace-pre-wrap font-mono text-[11px] text-text-secondary' },
            JSON.stringify(state.lastCommand.request)),
          h('pre', { className: `mt-1 overflow-x-auto whitespace-pre-wrap font-mono text-[11px] ${state.lastCommand.ok ? 'text-eucalyptus-300' : 'text-rose-300'}` },
            state.lastCommand.ok
              ? JSON.stringify(state.lastCommand.response)
              : state.lastCommand.error),
        )
      : null,
    state.events.length
      ? h('ul', { className: 'space-y-1' },
          ...state.events.map((event) =>
            h('li', { key: event.at, className: 'flex gap-2 text-xs' },
              h('span', { className: 'font-mono text-text-muted' }, formatTime(event.at)),
              h('span', { className: event.ok ? 'text-text-secondary' : 'text-rose-300' }, event.text),
            ),
          ),
        )
      : null,
  );
}
Download virtual-toy-server.mjs
virtual-toy-server.mjs
js
#!/usr/bin/env node
// The "hardware" for the Virtual Webhook Toy extension: a tiny HTTP server that
// holds a toy's state and prints every command it receives.
//
//   node devextensions/virtual-webhook-toy/virtual-toy-server.mjs
//   TOY_PORT=4400 TOY_TOKEN=secret node virtual-toy-server.mjs
//
// GET  /state    -> { ok, toy, history }
// POST /command  -> { action: 'set_level' | 'play_pattern' | 'stop', level?, pattern?, seconds? }

import { createServer } from 'node:http';

const PORT = Number(process.env.TOY_PORT) || 4400;
const TOKEN = process.env.TOY_TOKEN || '';
const PATTERNS = new Set(['pulse', 'wave', 'ramp']);

const toy = { power: false, level: 0, pattern: null, until: null, updatedAt: new Date().toISOString() };
const history = [];
let patternTimer = null;

function log(line) {
  console.log(`[virtual toy ${new Date().toLocaleTimeString()}] ${line}`);
}

function record(message) {
  toy.updatedAt = new Date().toISOString();
  history.unshift({ at: toy.updatedAt, message });
  history.splice(20);
  log(message);
}

function clearPattern() {
  if (patternTimer) clearTimeout(patternTimer);
  patternTimer = null;
  toy.pattern = null;
  toy.until = null;
}

function apply(command) {
  const action = String(command.action || '');

  if (action === 'stop') {
    clearPattern();
    toy.power = false;
    toy.level = 0;
    return `stopped${command.reason ? ` (${command.reason})` : ''}`;
  }

  if (action === 'set_level') {
    const level = Math.max(0, Math.min(10, Math.round(Number(command.level))));
    if (!Number.isFinite(level)) throw new Error('level must be a number from 0 to 10');
    clearPattern();
    toy.level = level;
    toy.power = level > 0;
    return level > 0 ? `steady at level ${level}` : 'stopped (level 0)';
  }

  if (action === 'play_pattern') {
    const pattern = String(command.pattern || '');
    if (!PATTERNS.has(pattern)) throw new Error(`pattern must be one of ${[...PATTERNS].join(', ')}`);
    const level = Math.max(1, Math.min(10, Math.round(Number(command.level) || 5)));
    const seconds = Math.max(1, Math.min(600, Math.round(Number(command.seconds) || 30)));
    clearPattern();
    toy.power = true;
    toy.level = level;
    toy.pattern = pattern;
    toy.until = new Date(Date.now() + seconds * 1000).toISOString();
    patternTimer = setTimeout(() => {
      clearPattern();
      toy.power = false;
      toy.level = 0;
      record(`${pattern} finished`);
    }, seconds * 1000);
    return `${pattern} pattern for ${seconds}s peaking at level ${level}`;
  }

  throw new Error(`unknown action "${action}"`);
}

function send(res, status, body) {
  res.writeHead(status, {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  });
  res.end(JSON.stringify(body));
}

function readBody(req) {
  return new Promise((resolve, reject) => {
    let raw = '';
    req.on('data', (chunk) => { raw += chunk; });
    req.on('end', () => {
      try {
        resolve(raw ? JSON.parse(raw) : {});
      } catch (err) {
        reject(new Error('body must be JSON'));
      }
    });
    req.on('error', reject);
  });
}

const server = createServer(async (req, res) => {
  if (req.method === 'OPTIONS') return send(res, 204, {});

  if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) {
    return send(res, 401, { ok: false, error: 'bad or missing bearer token' });
  }

  const url = new URL(req.url || '/', `http://${req.headers.host}`);

  if (req.method === 'GET' && url.pathname === '/state') {
    return send(res, 200, { ok: true, toy, history });
  }

  if (req.method === 'POST' && url.pathname === '/command') {
    try {
      const command = await readBody(req);
      const message = apply(command);
      record(message);
      return send(res, 200, { ok: true, toy, message });
    } catch (err) {
      log(`rejected: ${err.message}`);
      return send(res, 400, { ok: false, error: err.message });
    }
  }

  return send(res, 404, { ok: false, error: 'not found' });
});

server.listen(PORT, () => {
  log(`listening on http://localhost:${PORT}${TOKEN ? ' (token required)' : ''}`);
});
Download README.md
README.md
markdown
# Virtual Webhook Toy

A reference extension for AgenticLover API v7. The AI sends commands to a local HTTP server. The chat panel shows the request, response, current state, and the last eight events. No hardware is required.

The server records levels and timed patterns. It does not generate vibration waveforms or connect to a physical toy.

## Run it

1. Install Node.js 18 or later. No npm packages are required.
2. Put these four files in `devextensions/virtual-webhook-toy/`:
   - `manifest.json`
   - `virtual-webhook-toy.js`
   - `virtual-toy-server.mjs`
   - `README.md`
3. From the folder containing `devextensions`, run:

   ```sh
   node devextensions/virtual-webhook-toy/virtual-toy-server.mjs
   ```

   The server prints `listening on http://localhost:4400`. Keep this terminal open.
4. In AgenticLover, open Settings → Advanced. Turn on Developer Mode and Show developer features.
5. Open Dev Extensions. Pick the parent `devextensions` folder, scan, and install `virtual-webhook-toy`. Unload any existing dev extension first.
6. Open a chat and show the extension panel. Expand Virtual Webhook Toy and click Ping. The status changes to Idle.
7. Ask a model that supports tool calls: "Use toy_set_level to set the virtual toy to level 4."

The chat shows "Adjusted the toy". The panel shows level 4, the request JSON, and the server response. The server prints `steady at level 4`.

Dev extensions are enabled for every persona while installed. Unload this example when you finish. After editing the module, use Reload in Dev Extensions.

## Check the integration

- Ask for level 10. With the default Maximum level of 7, the server receives 7 and the tool result reports the clamp.
- Ask for a pulse at level 5 for 5 seconds. The server stops it when the timer ends. While open, the panel polls every two seconds and returns to Idle.
- Start another pattern and click the panel Stop button. Repeat with the top-bar Emergency Stop. Both send a stop command to the server.
- Ask the AI to stop to exercise `toy_stop`.
- Stop the server and ask for another command. The chat and panel show the failure. Restart the server and click Ping to reconnect.

Requests time out after five seconds. Failed emergency stops reject so the host can report the failure. The panel shows the last known state until the next successful request.

## Settings and local access

The extension sends requests from the app, not the AgenticLover backend. Run the server on the same computer as the app when using `localhost`.

Set `TOY_PORT` to change the port, then update Toy URL in the extension settings. Set `TOY_TOKEN` to require a bearer token, then enter it in Bearer token. For example, in a POSIX shell:

```sh
TOY_PORT=4401 TOY_TOKEN=example-token node devextensions/virtual-webhook-toy/virtual-toy-server.mjs
```

The server allows cross-origin requests for local development. Browser local network permissions can still block requests. If Ping stays disconnected, check the server terminal, Toy URL, token, and browser console.

## Tools

| Tool | Arguments | Result |
| --- | --- | --- |
| `toy_set_level` | `level`, 0 to 10 | Sets steady output. Zero stops the toy. |
| `toy_play_pattern` | `pattern`, `level`, `seconds` | Starts pulse, wave, or ramp for 5 to 120 seconds. |
| `toy_stop` | none | Stops output and clears the pattern timer. |

The module clamps levels to Maximum level, which defaults to 7. Tool results include the server message, a state description, and a clamp explanation when needed. Successful level and pattern tools also show a toast.

The persona receives a Tool Guidance contribution and a Current Toys entry. Settings come from `resolvedSettings` in hooks and the panel because `executeTool` does not receive `extensionState`. The hook and panel subscribe to module state through `useSyncExternalStore`.

## HTTP contract

```text
GET  /state
POST /command   { "action": "set_level", "level": 6 }
                { "action": "play_pattern", "pattern": "pulse", "level": 5, "seconds": 30 }
                { "action": "stop" }
```

Successful commands return HTTP 200 with this shape:

```json
{
	"ok": true,
	"toy": {
		"power": true,
		"level": 6,
		"pattern": null,
		"until": null,
		"updatedAt": "2026-09-25T00:00:00.000Z"
	},
	"message": "steady at level 6"
}
```

`GET /state` returns `{ ok: true, toy, history }`. History contains up to 20 entries with `at` and `message`. Timed patterns set `until` to an ISO timestamp. When a pattern ends, the server clears `pattern` and `until`, sets `power` to false, and sets `level` to zero.

The server returns `{ ok: false, error }` with HTTP 400 for invalid commands, 401 for a missing or incorrect configured token, and 404 for an unknown route. It answers OPTIONS requests for browser CORS checks.

The server accepts pattern durations of 1 to 600 seconds. The extension restricts the AI tools to 5 to 120 seconds. Maximum level is an extension setting, not a server restriction. Direct HTTP clients can set levels up to 10.

## Source and checks

The developer docs at `/docs/developer/example#virtual-webhook-toy` display and offer downloads of these same files. The docs build imports the source directly.

In a repository checkout, run the focused checks from this folder:

```sh
node --test virtual-webhook-toy.test.mjs
```

For the host contracts used here, see runtime exports and the development workflow.

Task Ledger

settingsnumber, toggleA cap on open tasks and a toast switch. Read through resolvedSettings.
getPromptContributionstool_guidanceTells the model the rules and lists open tasks every turn.
getTools / executeTooltwo toolsassign_task and complete_task with display labels for the chat cards.
variablespersona scope, sharedThe task list survives reloads and other extensions can read it.
onBeforeLLMRequesthookCaches resolvedSettings for the tools and hydrates the task list the first time a runtime is available.
ui.toastv6Announces a new task without interrupting.
PanelComponentchatPanelLists open tasks with a Done button and a one-tap prompt via props.chat.

manifest.json

json
{
  "apiVersion": 7,
  "version": "1.0.0",
  "key": "task-ledger",
  "name": "Task Ledger",
  "category": "integration",
  "icon": "ListChecks",
  "description": "Lets the persona assign tasks and tracks which ones are done.",
  "supportsPromptContributions": true,
  "supportsLifecycleHooks": true,
  "settings": {
    "fields": {
      "maxOpen": {
        "id": "maxOpen",
        "type": "number",
        "scope": "global",
        "title": "Maximum open tasks",
        "min": 1,
        "max": 10,
        "default": 3
      },
      "announce": {
        "id": "announce",
        "type": "toggle",
        "scope": "global",
        "title": "Toast when a task is assigned",
        "default": true
      }
    },
    "mainLayout": ["maxOpen", "announce"],
    "quickLayout": ["maxOpen"]
  },
  "chatPanel": { "order": 200 }
}

task-ledger.js

js
// task-ledger.js
const React = globalThis.React;
const h = React.createElement;

const KEY = 'tasks';
const SCOPE = { scope: 'persona' };

// In-memory mirror of the persisted list and the latest settings. Prompt
// contributions read this synchronously; the variables API keeps the list
// durable across reloads. Settings are cached here because tool calls do not
// receive extensionState.
const state = { tasks: [], settings: {} };

// Components subscribe with useSyncExternalStore. React re-subscribes whenever
// the subscribe function's identity changes, so a re-evaluated module instance
// still reaches the host's state slot.
const listeners = new Set();
let version = 0;
function publish() {
  version += 1;
  listeners.forEach((fn) => fn());
}
function subscribe(fn) {
  listeners.add(fn);
  return () => listeners.delete(fn);
}
const getVersion = () => version;

async function load(runtime) {
  try {
    const r = await runtime.variables.get(KEY, { ...SCOPE, defaultValue: [] });
    state.tasks = Array.isArray(r.value) ? r.value : [];
  } catch (err) {
    console.warn('[task-ledger] load failed', err);
  }
  publish();
}

async function save(runtime, tasks) {
  state.tasks = tasks;
  publish();
  try {
    await runtime.variables.set(KEY, tasks, { ...SCOPE, visibility: 'shared' });
  } catch (err) {
    console.warn('[task-ledger] save failed', err);
  }
}

const openTasks = () => state.tasks.filter((t) => !t.done);

export const manifest = {
  apiVersion: 7,
  version: '1.0.0',
  key: 'task-ledger',
  name: 'Task Ledger',
  category: 'integration',
  icon: 'ListChecks',
  description: 'Lets the persona assign tasks and tracks which ones are done.',
  supportsPromptContributions: true,
  supportsLifecycleHooks: true,
  settings: {
    fields: {
      maxOpen: { id: 'maxOpen', type: 'number', scope: 'global', title: 'Maximum open tasks', min: 1, max: 10, default: 3 },
      announce: { id: 'announce', type: 'toggle', scope: 'global', title: 'Toast when a task is assigned', default: true },
    },
    mainLayout: ['maxOpen', 'announce'],
    quickLayout: ['maxOpen'],
  },
  chatPanel: { order: 200 },
  PanelComponent: TaskPanel,
};

// ---- Lifecycle -----------------------------------------------------------

export async function onEnable() {
  // Nothing to load here: the runtime is only available on hooks that carry a ctx.
  // The first hook with ctx.runtime hydrates the cache instead.
}

export async function onBeforeLLMRequest(ctx) {
  state.settings = ctx.extensionState?.resolvedSettings || {};
  if (state.tasks.length === 0) await load(ctx.runtime);
}

// ---- Prompt --------------------------------------------------------------

export function getPromptContributions(ctx) {
  state.settings = ctx.extensionState?.resolvedSettings || {};
  const max = Number(state.settings.maxOpen) || 3;
  const open = openTasks();
  const lines = open.length
    ? open.map((t) => `- [${t.id}] ${t.title}`).join('\n')
    : '(none)';

  return [
    {
      placement: 'tool_guidance',
      priority: 10,
      text: `You may assign the user tasks with assign_task and mark them done with complete_task. `
        + `Keep at most ${max} open at once. Open tasks:\n${lines}`,
    },
  ];
}

// ---- Tools ---------------------------------------------------------------

export function getTools() {
  return [
    {
      type: 'function',
      function: {
        name: 'assign_task',
        description: 'Give the user a task to complete.',
        parameters: {
          type: 'object',
          properties: {
            title: { type: 'string', description: 'What the user must do, in one line.' },
          },
          required: ['title'],
        },
      },
      displayLabels: { pending: 'Assigning a task…', success: 'Assigned a task', error: "Couldn't assign the task" },
    },
    {
      type: 'function',
      function: {
        name: 'complete_task',
        description: 'Mark a task as done once the user has completed it.',
        parameters: {
          type: 'object',
          properties: {
            id: { type: 'string', description: 'The task id from the open task list.' },
          },
          required: ['id'],
        },
      },
      displayLabels: { pending: 'Updating the ledger…', success: 'Marked a task done', error: "Couldn't update the ledger" },
    },
  ];
}

export async function executeTool(name, args, ctx) {
  if (state.tasks.length === 0) await load(ctx.runtime);
  const settings = state.settings;

  if (name === 'assign_task') {
    const max = Number(settings.maxOpen) || 3;
    if (openTasks().length >= max) {
      return { ok: false, reason: `Already ${max} open tasks. Complete one first.` };
    }
    const task = { id: Math.random().toString(36).slice(2, 8), title: String(args.title), done: false };
    await save(ctx.runtime, [...state.tasks, task]);
    if (settings.announce !== false) {
      ctx.runtime.ui.toast({ message: `New task: ${task.title}`, level: 'info' });
    }
    return { ok: true, task };
  }

  if (name === 'complete_task') {
    const task = state.tasks.find((t) => t.id === args.id);
    if (!task) return { ok: false, reason: 'No task with that id.' };
    await save(ctx.runtime, state.tasks.map((t) => (t.id === args.id ? { ...t, done: true } : t)));
    return { ok: true, remainingOpen: openTasks().length };
  }

  throw new Error(`Unknown tool: ${name}`);
}

// ---- Panel ---------------------------------------------------------------

function TaskPanel(props) {
  React.useSyncExternalStore(subscribe, getVersion);

  React.useEffect(() => {
    if (props.runtime) void load(props.runtime);
  }, [props.runtime, props.chat?.currentPersonaId]);

  const markDone = (id) => {
    if (!props.runtime) return;
    void save(props.runtime, state.tasks.map((t) => (t.id === id ? { ...t, done: true } : t)));
    props.chat?.sendMessage(`I finished the task: ${state.tasks.find((t) => t.id === id)?.title}`);
  };

  const open = openTasks();

  return h('div', { className: 'space-y-2' },
    open.length === 0
      ? h('p', { className: 'text-sm text-text-muted' }, 'No open tasks.')
      : open.map((task) =>
          h('div', { key: task.id, className: 'flex items-center justify-between gap-2 rounded-lg bg-bg-raised px-3 py-2' },
            h('span', { className: 'text-sm text-text-primary' }, task.title),
            h('button', {
              className: 'text-xs text-eucalyptus-300 hover:underline',
              onClick: () => markDone(task.id),
            }, 'Done'),
          ),
        ),
    h('button', {
      className: 'text-xs text-orchid-300 hover:underline',
      onClick: () => props.chat?.sendMessage('Give me a task.'),
    }, 'Ask for a task'),
  );
}

Notes

The module keeps the task list in a plain object and treats the variables API as its durable copy. Prompt contributions are collected on every turn, so they read the object instead of awaiting the network. The panel subscribes to a tiny listener set through useSyncExternalStore so a tool call and a button press update the same UI.

No useExtension is exported. The host's default reports the extension as ready and the tools and hooks still run. Add one only when you have live status to show.

✦
Put both files in a folder named task-ledger inside your workspace and follow the Dev Extensions steps. The first message that makes the persona call assign_task will show the toast, the chat card, and the panel updating together.