Creating a Module

Modules are the primary way to extend Gumm’s capabilities. Each module adds new tools that the LLM can call automatically during a conversation. This guide walks you through creating your first module from scratch.


What is a module?

A module is a folder placed in modules/user/ that contains:

  • a manifest.json describing the module (name, version, tools, schedules, etc.)
  • an index.ts exporting the tool definitions and the handler function
  • an optional ui.vue for a custom settings panel in the dashboard

Gumm watches modules/user/ continuously. When you drop a folder there, it is detected, validated, and loaded without restarting the server.


Quickstart — Minimal module

Before reading all the details, here is the smallest possible working module:

modules/user/hello-world/manifest.json

{
  "id": "hello-world",
  "name": "Hello World",
  "version": "1.0.0",
  "description": "A minimal example module.",
  "entrypoint": "index.ts",
  "capabilities": []
}

modules/user/hello-world/index.ts

export function tools() {
  return [
    {
      type: 'function',
      function: {
        name: 'hello_greet',
        description: 'Greet someone by name.',
        parameters: {
          type: 'object',
          properties: {
            name: { type: 'string', description: 'The name to greet' },
          },
          required: ['name'],
        },
      },
    },
  ];
}

export async function handler(toolName: string, args: Record<string, any>) {
  if (toolName === 'hello_greet') {
    return JSON.stringify({ message: `Hello, ${args.name}!` });
  }
  return `Unknown tool: ${toolName}`;
}

Drop the hello-world/ folder into modules/user/ and ask Gumm to greet someone — it will automatically use it.


File structure

For modules with multiple tools, splitting the code into separate files is recommended:

modules/user/my-module/
├── manifest.json       # Required — metadata and configuration
├── index.ts            # Required — exports tools() and handler()
├── tools.ts            # Tool definitions (re-exported from index.ts)
├── types.ts            # TypeScript types
├── utils.ts            # Shared helpers
└── handlers/
    ├── index.ts        # Handler router (re-exported from root index.ts)
    ├── action-a.ts     # Handlers grouped by domain
    └── action-b.ts

Keep each file under ~300 lines. The root index.ts should only import and re-export.


manifest.json — Reference

FieldTypeRequiredDefaultDescription
idstringUnique identifier, kebab-case (e.g. my-tool)
namestringDisplay name shown in the dashboard
versionstring"1.0.0"Semver
descriptionstring""Short description shown in the UI
entrypointstring"index.ts"Entry file name
capabilitiesstring[][]Declared capabilities (e.g. ["fetch", "spotify"])
dependenciesstring[][]IDs of other modules this one depends on
schemaobjectJSON schema of tool arguments (for documentation)
schedulesarray[]CRON jobs to register automatically (see below)
authorobject{ name, url } — shown in the UI
repositorystringGitHub owner/repo for update support
examplesstring[]Example prompts shown in the dashboard
defaultEnabledbooleantrueSet to false to install but not activate by default

Example

{
  "id": "exchange-rates",
  "name": "Exchange Rates",
  "version": "1.0.0",
  "description": "Get live currency exchange rates. No API key required.",
  "entrypoint": "index.ts",
  "capabilities": ["fetch"],
  "author": {
    "name": "Your Name"
  },
  "examples": ["What's the EUR to USD rate today?", "Convert 200 GBP to JPY"]
}

index.ts — Entrypoint

The entrypoint must export two functions: tools and handler.

tools(): ToolDefinition[]

Returns the list of OpenAI-compatible tool definitions that are injected into the LLM payload. The LLM reads these descriptions to decide when and how to call your tools.

export function tools() {
  return [
    {
      type: 'function' as const,
      function: {
        name: 'exchange_rate_get', // Prefix with module ID
        description: 'Get the exchange rate between two currencies.',
        parameters: {
          type: 'object',
          properties: {
            from: {
              type: 'string',
              description: 'Source currency code (e.g. "EUR", "USD")',
            },
            to: {
              type: 'string',
              description: 'Target currency code (e.g. "JPY", "GBP")',
            },
          },
          required: ['from', 'to'],
        },
      },
    },
  ];
}

Rules for tool names:

  • Always prefix with your module ID: exchange_rate_get, not get
  • Use snake_case
  • Be specific — the description is what the LLM reads to decide when to call your tool

handler(toolName, args, ctx?): Promise<string>

Called by Gumm when the LLM wants to use one of your tools. Returns a JSON string.

ParameterTypeDescription
toolNamestringThe tool name from the LLM call
argsRecord<string, any>Arguments parsed from the LLM response
ctxModuleContext (optional)Injected context: memory, storage, events, brain …
export async function handler(
  toolName: string,
  args: Record<string, any>,
  ctx?: any,
): Promise<string> {
  switch (toolName) {
    case 'exchange_rate_get': {
      const res = await fetch(
        `https://api.exchangerate-api.com/v4/latest/${args.from}`,
      );
      const data = await res.json();
      const rate = data.rates[args.to];
      return JSON.stringify({ from: args.from, to: args.to, rate });
    }
    default:
      return `Unknown tool: ${toolName}`;
  }
}

Always wrap your handler body in a try/catch and return an error string instead of throwing. A thrown error surfaces to the user as a confusing LLM error.


ModuleContext — ctx

When Gumm calls your handler, it injects a ctx object with everything your module might need.

interface ModuleContext {
  memory: NamespacedMemory; // Persist data for the LLM to read
  storage: ModuleStorage; // Persist structured internal data
  events: EventAccess; // Emit / subscribe to events
  modules: ModulesAccess; // Call other modules directly
  brain: BrainAccess; // Read brain config and memory
  log: Logger; // Structured logging
}

ctx.memory — LLM-readable memory

Stores key-value data in the brain’s memory, scoped to your module. The LLM can read this when relevant.

MethodDescription
remember(key, value, type?)Save a value. Types: fact, preference, context, event
recall(key)Get a value by key
recallAll()Get all entries for this module
forget(key)Delete an entry
// Remember the last location the user searched
await ctx.memory.remember('last_location', args.from, 'context');

// Read it back
const last = await ctx.memory.recall('last_location');

ctx.storage — Internal structured storage

Stores timestamped records in a module_data table. Unlike memory, this is not read by the LLM — it’s for internal module state: snapshots, history, cached data.

MethodDescription
store(key, value)Append a JSON record under key (timestamped)
get(key)Get the most recent value for key
list(key, { since?, until?, limit? })List records, newest first (max 100)
remove(key)Delete all records for key
// Save a daily snapshot
await ctx.storage.store('rate_snapshot', { eur_usd: 1.09 });

// Retrieve it later
const latest = await ctx.storage.get('rate_snapshot');

// Get last 7 days
const history = await ctx.storage.list('rate_snapshot', {
  since: new Date(Date.now() - 7 * 86400_000),
  limit: 7,
});

Data is scoped to your module. It is automatically deleted when the module is uninstalled.

ctx.events — Event bus

Emit events that other modules can listen to, and subscribe to events from other modules.

MethodDescription
emit(type, payload?)Emit an event (source = your module ID)
on(pattern, handler)Subscribe to events. Supports wildcards: "module.*", "*"
off(pattern, handler)Unsubscribe
// Emit an event after a successful operation
await ctx.events.emit('exchange_rate.fetched', {
  from: 'EUR',
  to: 'USD',
  rate: 1.09,
});

// React to events from another module
ctx.events.on('calendar.event.created', async (event) => {
  ctx.log.info('New calendar event:', event.payload);
});

Subscriptions are automatically cleaned up when the module is unloaded.

ctx.modules — Call other modules (RPC)

Call another module’s handler directly, without going through the LLM.

MethodDescription
call(moduleId, toolName, args)Invoke another module’s tool directly
list()List all currently loaded modules
// Send an email via the gmail module
const result = await ctx.modules.call('gmail', 'gmail_send', {
  to: 'me@example.com',
  subject: 'Daily rate',
  body: `EUR/USD today: ${rate}`,
});

ctx.brain — Read brain config

MethodDescription
getConfig(key)Read a value from the brain configuration
recall(key)Read from the brain memory namespace
// Read the user's configured timezone
const tz = await ctx.brain.getConfig('brain.timezone');

// Read a stored API credential (stored in dashboard → APIs)
const token = await ctx.brain.getConfig('api.my-connection.token');

ctx.log — Logging

Logs appear in the server console prefixed with your module ID.

ctx.log.info('Fetching rate for', args.from);
ctx.log.warn('Rate API slow, response took > 3s');
ctx.log.error('API call failed:', err.message);

CRON schedules

Modules can declare CRON jobs in the manifest. They start automatically when the module is loaded and stop when it is disabled or removed.

{
  "id": "exchange-rates",
  "schedules": [
    {
      "name": "daily-snapshot",
      "cron": "0 8 * * *",
      "handler": "daily_snapshot",
      "description": "Saves EUR/USD rate every morning at 8:00.",
      "payload": { "from": "EUR", "to": "USD" }
    }
  ]
}

The handler value must match an exported function name in index.ts. The scheduler calls it like any tool: handler("daily_snapshot", payload, ctx).

// index.ts — add this alongside your main handler
export async function daily_snapshot(
  toolName: string,
  args: Record<string, any>,
  ctx?: any,
): Promise<string> {
  const res = await fetch(
    `https://api.exchangerate-api.com/v4/latest/${args.from}`,
  );
  const data = await res.json();
  await ctx?.storage.store('rate_snapshot', { rate: data.rates[args.to] });
  return 'snapshot saved';
}

Schedule fields:

FieldTypeRequiredDescription
namestringUnique name within the module
cronstringCron expression (croner syntax, e.g. "0 8 * * *")
handlerstringExported function name in index.ts
descriptionstringShown in the dashboard
payloadobjectStatic arguments passed to the handler

Schedules can be enabled, disabled, and run manually from the dashboard.


Reading API credentials

If your module needs an API key or OAuth token stored by the user, read it from the Brain config:

// Convention: api.<connection-id>.<field>
const token = await ctx.brain.getConfig('api.github.token');
const clientId = await ctx.brain.getConfig('api.spotify.clientId');

Connections and their credentials are configured by the user in Dashboard → APIs. Do not hard-code tokens or read them from environment variables — store them exclusively through the brain config system.


Full example — multi-tool module with context

Here is a complete, realistic module that uses multiple tools, stores state, and uses context:

modules/user/exchange-rates/manifest.json

{
  "id": "exchange-rates",
  "name": "Exchange Rates",
  "version": "1.0.0",
  "description": "Get live currency exchange rates and track history.",
  "entrypoint": "index.ts",
  "capabilities": ["fetch"],
  "defaultEnabled": false,
  "schedules": [
    {
      "name": "daily-eur-usd",
      "cron": "0 8 * * *",
      "handler": "daily_snapshot",
      "description": "Snapshot EUR/USD every morning.",
      "payload": { "from": "EUR", "to": "USD" }
    }
  ],
  "examples": [
    "What's the EUR to USD rate?",
    "Show me the last 7 days of exchange rate history"
  ]
}

modules/user/exchange-rates/index.ts

import type { ToolDefinition } from '../../../back/utils/module-types';

export function tools(): ToolDefinition[] {
  return [
    {
      type: 'function',
      function: {
        name: 'exchange_rate_get',
        description: 'Get the current exchange rate between two currencies.',
        parameters: {
          type: 'object',
          properties: {
            from: {
              type: 'string',
              description: 'Source currency code (e.g. EUR)',
            },
            to: {
              type: 'string',
              description: 'Target currency code (e.g. USD)',
            },
          },
          required: ['from', 'to'],
        },
      },
    },
    {
      type: 'function',
      function: {
        name: 'exchange_rate_history',
        description: 'Show the stored history of exchange rate snapshots.',
        parameters: {
          type: 'object',
          properties: {
            days: {
              type: 'number',
              description: 'How many days back (default 7)',
            },
          },
          required: [],
        },
      },
    },
  ];
}

export async function handler(
  toolName: string,
  args: Record<string, any>,
  ctx?: any,
): Promise<string> {
  try {
    switch (toolName) {
      case 'exchange_rate_get': {
        const res = await fetch(
          `https://api.exchangerate-api.com/v4/latest/${args.from}`,
        );
        const data = await res.json();
        const rate = data.rates[args.to];

        // Persist the last queried pair in memory so the LLM can refer to it
        await ctx?.memory.remember(
          'last_pair',
          `${args.from}/${args.to}`,
          'context',
        );
        // Store internal snapshot for history
        await ctx?.storage.store('snapshot', {
          from: args.from,
          to: args.to,
          rate,
        });

        return JSON.stringify({ from: args.from, to: args.to, rate });
      }

      case 'exchange_rate_history': {
        const days = args.days ?? 7;
        const records = await ctx?.storage.list('snapshot', {
          since: new Date(Date.now() - days * 86400_000),
          limit: days,
        });
        return JSON.stringify(records ?? []);
      }

      default:
        return `Unknown tool: ${toolName}`;
    }
  } catch (err) {
    return `Error: ${err instanceof Error ? err.message : String(err)}`;
  }
}

// Called by the daily-eur-usd CRON schedule
export async function daily_snapshot(
  toolName: string,
  args: Record<string, any>,
  ctx?: any,
): Promise<string> {
  return handler('exchange_rate_get', args, ctx);
}

Installing a module

Drop it in the folder (local development)

modules/user/my-module/
├── manifest.json
└── index.ts

Gumm detects the new folder and loads it automatically within a few seconds. No restart needed.

From a GitHub repository

Via the dashboard (Modules → Install from GitHub) or the API:

curl -X POST http://your-server:3000/api/modules/install \
  -H 'Content-Type: application/json' \
  -d '{ "repo": "your-username/your-module", "ref": "main" }'

The script downloads the repository, validates the manifest and exports, and moves it into modules/user/.


Managing modules

After installation, manage your modules from the dashboard (Modules page) or via the API:

ActionDashboardAPI
Enable/disableToggle switch on the module cardPUT /api/modules/:id/toggle
Update”Update” buttonPOST /api/modules/:id/update
Uninstall”Remove” buttonDELETE /api/modules/:id
Reload all”Reload” buttonPOST /api/modules/reload

Best practices

  • Prefix all tool names with your module ID: exchange_rate_get, not get_rate.
  • Return errors as strings, never throw from handler. The user sees the return value directly.
  • Use ctx.memory to preserve state the LLM should know about across conversations.
  • Use ctx.storage for structured internal data (snapshots, history, cache).
  • Keep handlers fast — the user is waiting for the LLM to respond.
  • Set defaultEnabled: false for modules that require configuration (API keys, OAuth) before they can work.
  • Use ctx.log instead of console.log so logs are prefixed and identifiable.
  • Never hard-code credentials — always read them via ctx.brain.getConfig('api.<connection>.<field>').
  • Declare capabilities in the manifest ( "fetch", "spotify", etc.) for transparency and future capability gating.