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.jsondescribing the module (name, version, tools, schedules, etc.) - an
index.tsexporting the tool definitions and the handler function - an optional
ui.vuefor 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
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | ✅ | — | Unique identifier, kebab-case (e.g. my-tool) |
name | string | ✅ | — | Display name shown in the dashboard |
version | string | — | "1.0.0" | Semver |
description | string | — | "" | Short description shown in the UI |
entrypoint | string | — | "index.ts" | Entry file name |
capabilities | string[] | — | [] | Declared capabilities (e.g. ["fetch", "spotify"]) |
dependencies | string[] | — | [] | IDs of other modules this one depends on |
schema | object | — | — | JSON schema of tool arguments (for documentation) |
schedules | array | — | [] | CRON jobs to register automatically (see below) |
author | object | — | — | { name, url } — shown in the UI |
repository | string | — | — | GitHub owner/repo for update support |
examples | string[] | — | — | Example prompts shown in the dashboard |
defaultEnabled | boolean | — | true | Set 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, notget - 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.
| Parameter | Type | Description |
|---|---|---|
toolName | string | The tool name from the LLM call |
args | Record<string, any> | Arguments parsed from the LLM response |
ctx | ModuleContext (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/catchand 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.
| Method | Description |
|---|---|
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.
| Method | Description |
|---|---|
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.
| Method | Description |
|---|---|
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.
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Unique name within the module |
cron | string | ✅ | Cron expression (croner syntax, e.g. "0 8 * * *") |
handler | string | ✅ | Exported function name in index.ts |
description | string | — | Shown in the dashboard |
payload | object | — | Static 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:
| Action | Dashboard | API |
|---|---|---|
| Enable/disable | Toggle switch on the module card | PUT /api/modules/:id/toggle |
| Update | ”Update” button | POST /api/modules/:id/update |
| Uninstall | ”Remove” button | DELETE /api/modules/:id |
| Reload all | ”Reload” button | POST /api/modules/reload |
Best practices
- Prefix all tool names with your module ID:
exchange_rate_get, notget_rate. - Return errors as strings, never throw from
handler. The user sees the return value directly. - Use
ctx.memoryto preserve state the LLM should know about across conversations. - Use
ctx.storagefor structured internal data (snapshots, history, cache). - Keep handlers fast — the user is waiting for the LLM to respond.
- Set
defaultEnabled: falsefor modules that require configuration (API keys, OAuth) before they can work. - Use
ctx.loginstead ofconsole.logso 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.