Authoring Agents

An Agentum agent is a plugin: a folder of JavaScript that runs inside the agent container. It has a manifest.json declaring identity and capabilities, an index.js exporting lifecycle hooks, and optional lib/ modules and a control-ui/panel.js dashboard. Agents run recurring work via ctx.schedule.every(ms, fn) and ctx.schedule.dailyAt('HH:MM', fn), or react to events. They reach the outside world through ctx.services.* (Telegram, AI, calendar, email, host-agent) and read other plugins’ data through ctx.data.read(pluginId, table).

You don’t need to know any of that to build one. Read on.

Two paths

There are two ways to get to a working agent:

  1. Agent Builder (chat) — describe what you want in plain English. Recommended for your first agent. The builder writes the manifest, the index.js, an optional lib module, and an optional dashboard panel.
  2. Hand-coded — open your editor in your plugins folder and write the files yourself. Faster once you’ve done it a few times, and the only path if you want to iterate on logic the LLM keeps getting wrong.

Most people do (1) for the scaffold and then drop into (2) to refine.

Agent Builder

Where to find it

In the Agentum sidebar, click Create Agent (the sparkle icon). A chat panel opens on the left, with a live preview pane on the right.

The flow

Plan phase

Tell the chat what you want. “Send me a Telegram every morning with the day’s calendar events and the weather.” The LLM asks clarifying questions — what time? what city? — and drafts a markdown plan in the right panel. Each turn, the plan is regenerated based on the full conversation, so you can refine by replying.

Approve plan

When the plan reads right, click Approve. This locks in the structural commitment — what files will be written, what integrations will be used, what cross-plugin reads will be declared.

Power-ups (optional)

The builder proposes 2–4 super-charged extensions to the approved plan — things like “add a snooze button” or “summarize the events with AI before sending”. Check the ones you want, and optionally type a custom power-up of your own. The plan updates and the builder asks again. Loop as many rounds as you like, then move on.

Build

Click Create. The orchestrator runs each generation stage in sequence — manifest, optional lib, index, optional panel — then a cross-file consistency check, then writes the files to disk. The progress bar shows which stage you’re in. Tier-1 builds (manifest + index only) finish in 10–20 seconds; Tier-2 builds with a custom panel take roughly twice that.

Verify

Your new plugin appears in the sidebar within a few seconds. Click it to see the dashboard panel (if one was generated) or to inspect the Configuration and Permissions tabs. Reveal in Finder opens the plugin folder if you want to read the generated source.

The plugin’s declared integrations and cross-plugin reads are auto-granted on install — open the Permissions tab to see them green and adjust if needed.

What it produces

Tier Files written When
Tier-1 manifest.json, index.js Default. Anything that fits in one entry-point file.
Tier-2 (lib) + lib/<name>.js When the logic is non-trivial enough to deserve its own module.
Tier-2 (panel) + control-ui/panel.js When the agent benefits from a custom dashboard panel.
Tier-2 (both) + both files A custom UI driven by a substantive backend.

The builder picks the tier based on what your plan asked for — you don’t choose explicitly.

What can go wrong

A lightweight validator runs on every generated file: malformed JSON, missing exports, panel routes with no backend handler, that sort of thing. If validation fails, the builder runs one repair pass with the errors fed back to the model. If that also fails, you’ll see the validator errors and the model’s last attempt in the chat — nothing is written to disk. Either revise the plan and try again, or hand-edit from there.

Hand-coding a plugin

Where plugins live

Each plugin is a folder under your plugins directory (~/Documents/YuristicPlugs by default — set during onboarding). The folder name doesn’t have to match the plugin id, but by convention it does.

~/Documents/YuristicPlugs/my-feature/
├── manifest.json        # identity, integrations declared, config schema
├── index.js             # init / routes / handlers
├── lib/                 # optional — shared modules
│   └── my-feature.js
├── control-ui/          # optional — custom dashboard panel
│   └── panel.js
├── data/                # auto-managed — backing files for ctx.store + ctx.data reads
├── store/               # optional — typed store schema (store/schema.js)
└── settings.json        # auto-generated, gitignored, mode 0600 — your config values

settings.json is per-user — it holds the values typed into your Configuration tab and is created by the settings UI on first save. Don’t commit it; don’t ship it.

Minimal manifest

{
  "id": "hello",
  "name": "Hello World",
  "version": "1.0.0",
  "description": "Pings me on Telegram every five minutes.",
  "enabled": true,
  "apiPrefix": "/api/hello",
  "permissions": ["telegram:send"],
  "uses": ["telegram"],
  "dataReads": []
}

That’s the floor. Important fields:

  • id — must be unique across your installed plugins. Kebab-case.
  • uses — integrations your plugin calls (telegram, ai, calendar, email, hostAgent). Auto-granted on install; the user can revoke specific entries from your Permissions tab.
  • dataReads — cross-plugin reads (<sourcePluginId>/<table>). Empty array if you don’t need any. Auto-granted on install.
  • permissions — capability hints used by the integration-inference fallback. Optional; informational.

For user-tunable settings (an API key, an interval, a mode toggle), add a config block in the manifest. Each field declares a type (string, credential, number, boolean, select) with optional defaults. The Configuration tab auto-renders an editor for each one and persists user input to settings.json:

"config": {
  "interval": { "type": "number", "default": 300, "label": "Interval (s)" },
  "mode":     { "type": "select", "default": "auto", "label": "Mode",
                "options": [{"value":"auto","label":"Auto"},
                            {"value":"manual","label":"Manual"}] }
}

You don’t need tickIntervalMs, tickId, or tickRunImmediately in the manifest anymore — register recurring jobs inside init(ctx) (see below). The old manifest tick fields still work as a back-compat shim for older plugins, but new code shouldn’t use them.

Minimal index.js

'use strict';

const { log } = require('../../core/utils/logger');

module.exports = {
  id: 'hello',

  init(ctx) {
    log('[hello] init');

    // Recurring work. Pick ONE of these patterns:

    // Every five minutes
    ctx.schedule.every(300_000, async () => {
      const chatId = ctx.config.TELEGRAM_ALLOWED_USER_ID;
      if (!ctx.services.telegram.isReady()) return;
      await ctx.services.telegram.send(chatId, 'Hello from your agent.');
    }, { tickId: 'hello' });

    // Or once a day at 07:30 local
    // ctx.schedule.dailyAt('07:30', async () => { … }, {
    //   timezone: 'America/Los_Angeles',
    //   tickId: 'hello-morning',
    // });
  },
};

Just one required hook — init(ctx) runs once when the agent container starts. Register your slash commands, your scheduled jobs, your event handlers, your ctx.fn exports — all in one place.

The scheduled function can throw freely; the scheduler catches errors and logs them with plugin attribution. Pass tickId: 'your-plugin-id' if you want a friendly name in the Schedulers tab.

The ctx object

Field What it is
ctx.config Read-only .env values. Use for shared infrastructure credentials (Telegram bot token, LLM API keys, SMTP).
ctx.pluginConfig Your plugin’s own settings, merged from settings.json over manifest.config defaults. Use for plugin-private values.
ctx.schedule.every(ms, fn, opts?) Register a recurring job. Returns { tickId, unregister() }.
ctx.schedule.dailyAt('HH:MM', fn, opts?) Register a daily-fire job in opts.timezone (IANA name).
ctx.services.telegram send, sendAudio, edit, callApi, isReady — the Telegram bot surface.
ctx.services.ai (or gemini) generate(prompt), runSession, transcribeAudio, isReady. Dispatches across providers based on user’s LLM choice.
ctx.services.calendar fetchEvents, fetchBusyTimes, fetchRecent, findFreeSlots, isReady.
ctx.services.email send, health, testRoundTrip, isReady.
ctx.services.hostAgent Reach the macOS host: keepSave (Reminders), powerSleep, sessionsList, sessionsOpen.
ctx.data.read(pluginId, table) Read another plugin’s data/<table>.json. Returns a deep-cloned array (mutating it doesn’t affect the source). Returns [] on any failure — denied, missing, malformed.
ctx.store Typed per-plugin store (when you ship store/schema.js). get, set, query, mutate, delete. Persists to data/<table>.json.
ctx.plugins.isEnabled(id) Is plugin X currently enabled? Use for graceful degradation when a sibling might not be installed.
ctx.events Pub/sub bus. ctx.events.on('x', handler) / ctx.events.emit('x', data).
ctx.fn Cross-plugin function registry. Other plugins register helpers here for you to call.

Always guard service calls with isReady() if your plugin should degrade gracefully when an integration isn’t configured or the user has revoked the grant:

if (ctx.services.calendar.isReady()) {
  const events = await ctx.services.calendar.fetchEvents();
  // ...
}

Calling an un-granted integration’s methods directly (without the isReady() guard) returns a rejected promise with a clear message: “Integration X is not granted to this plugin. Grant it from the Permissions panel in the main app.” Helpful, but you should still guard for the user who hasn’t configured the integration at all.

Permissions and grants

Two declarations in your manifest control what your plugin can touch:

  • uses — integrations (telegram, ai, calendar, email, hostAgent)
  • dataReads — cross-plugin reads (<sourceId>/<table> strings)

When the user installs your plugin from the catalog, Agentum auto-grants both lists. The user can revoke individual entries from your Permissions tab — flipping a toggle off makes the corresponding access return a denial stub (for integrations) or an empty array (for data reads), and the plugin reloads so your code sees the new state immediately.

This means your plugin should treat “not granted” the same way it treats “not configured” — guard ctx.services.X calls with isReady(), and check the length of ctx.data.read(...) results before using them. A well-behaved plugin keeps running with reduced capability rather than crashing on revoked access.

Cross-plugin data reads

The dataReads declaration lets your plugin compose information from other plugins. The classic case is a morning digest pulling forecasts, headlines, and market signals from weather/news/polymarket and sending one message:

// manifest.json
{
  "id": "morning-digest",
  "uses": ["telegram", "ai"],
  "dataReads": ["weather/forecast", "news/headlines", "polymarket/markets"]
}
// index.js
init(ctx) {
  ctx.schedule.dailyAt('07:30', async () => {
    const forecast  = ctx.data.read('weather', 'forecast');
    const headlines = ctx.data.read('news', 'headlines');
    const markets   = ctx.data.read('polymarket', 'markets');

    if (!forecast.length && !headlines.length && !markets.length) return;

    const summary = await ctx.services.ai.generate(
      `Compose a 4-sentence morning digest from: ${JSON.stringify({ forecast, headlines, markets })}`,
    );
    await ctx.services.telegram.send(
      ctx.config.TELEGRAM_ALLOWED_USER_ID,
      summary,
      { parse_mode: 'Markdown' },
    );
  }, { timezone: 'America/Los_Angeles', tickId: 'morning-digest' });
}

A few things to know:

  • Read-only by design. Cross-plugin writes are not supported. If you need another plugin to do something, emit an event with ctx.events.emit('your-plugin:something-happened', payload) and let that plugin subscribe.
  • Empty array on every failure mode. Denied grant, missing target plugin, missing table file, malformed JSON — they all return []. So if (forecast.length) is the cleanest way to handle the “not available” case.
  • Loader ordering is automatic. A plugin declaring dataReads: ["weather/forecast"] is initialised after weather — no explicit loadAfter needed.

HTTP routes (optional)

If you’re building a custom dashboard panel, you’ll want to expose data to it. Add a routes(router, ctx) export:

routes(router, ctx) {
  router.get('/api/hello/status', async ({ res }) => {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok', lastTick: Date.now() }));
  });
},

Your panel then calls ctx.api('/api/hello/status') to read it. The full panel.js contract — what’s on ctx, lifecycle hooks, hot-reload semantics — lives in the example plugins shipped under your plugins folder; copying one is the fastest way to get started.

Hot reload

Saving a control-ui/panel.js file hot-reloads the panel in the running app — under a second, no restart. Changes to index.js, manifest.json, or anything in lib/ require a container restart: SystemServicesRestart agent. One click.

Changes to grants (Permissions tab toggles) DO hot-reload — the plugin’s init() re-runs and your captured grant set refreshes. No restart needed.

Sharing an agent

For one-off sharing — sending a plugin to a teammate or a friend — a plugin is just a folder, so:

  1. Zip the plugin folder.
  2. Send it.
  3. The recipient drops it into their plugins folder. The file watcher picks up the new folder and offers to load it — confirm to install and grant the integrations the plugin declares.

For wider distribution to any Agentum user, publish to the marketplace — see Publishing to the Marketplace. The publish flow is a wizard in the desktop app; the result is a card on the public marketplace that anyone with an active license can install with one click. Versioning, channels, updates, and screenshots are first-class.

settings.json is per-user and gitignored by convention. Whichever way you share, ship manifest.json and index.js (and lib/ and control-ui/ if present); let each user fill in their own keys via the Configuration tab and grant integrations via the Permissions tab.

Next steps