Skip to content
KeelOps
Contents
  1. 00 The KeelOps plugin SDK
  2. 01 Getting started
  3. 02 The manifest
  4. 03 The context and create()
  5. 04 Data: reading and owning tables
  6. 05 Pages in the application
  7. 06 Local AI: reports and release notes
  8. 07 Working with the core
  9. 08 Security and rules
  10. 09 Testing and shipping
  11. 10 SDK reference

The context and create()

plugin.mjs exports two things: manifest and create(ctx). The core calls create once, at start-up, with the context — everything the plugin may use from the core — and mounts what it returns.

export async function create(ctx) {
  return {
    routes: [/* [method, regexp, handler] */],
    staticDir: "/absolute/path/to/ui",
    migrate: (db, from) => { /* bring own tables to manifest.schemaVersion */ },
  };
}

create may be async. It runs before the core prepares the plugin's tables: don't read or write your own tables inside create — do it at the first request (see Data).

What create() receives: ctx

fieldtypewhat it is
dbdriverThe database driver: reads the KeelOps database, and writes the plugin's own tables when it has a nick. See Data.
sessionUser(req)async → User | nullThe real authentication of KeelOps: the person behind the request, or null.
dataDirstringA folder of the plugin's own under the installation's data directory: files, caches, the extension store. It survives updates; the application folder does not (and is read-only in production).
publicUrlstringThe plugin's public address, https://…/plugins/<name> — for links in emails, OAuth redirects, anything that leaves the browser.
keelopsUrlstringThe public address of KeelOps itself.
ollamaUrlstring | nullThe local models server, if configured (both editions). Pass it to ollama.mjs.
edizionestring"commerciale" or "community". →
funzioniSetThe functions of this core, by name. →
sdkDirstringWhere the SDK lives: load its modules from here.
pluginsDirstringWhere the plugins live (a pro edition loads its free base from here).
tasksobjectCreate and read tasks with the core's rules. →
attachmentsobjectRead and write attachments with the core's rules. →
perimeterobjectcanEditTask(userId, taskId): the core's own permission check. →
groupsobjectOwn a group of people. →
pluginsobject | nullstrumenti(): the tools other plugins offer. Only with the plugins:strumenti permission. →
segnaliobjectinvia(userId, data): refresh something in that person's browser, now. →
googleobject | nullThe Google OAuth client, only with the google:oauth permission and in the commercial edition.

Edition and functions

ctx.edizione says which edition is running; ctx.funzioni is a read-only Set of what this core has: the commercial modules by name (ticket, timesheet, indice-modelli, note-di-rilascio, analisi-offerte, integrazioni, modulo-iniettabile, area-investitori) and ollama when the local models server is configured, in either edition. On a community core without Ollama the set is empty.

Adapt to it rather than to the edition name: a door that depends on a missing function is null (ctx.timesheet without timesheet, ollamaUrl without ollama), and the data behind it is not there either — a plugin that would show "0 requests" on a core without tickets is saying something false. Hide what has no data.

Hours are a special case: the timesheet grid (people log hours on tasks) is in every edition, so TimeEntry always has meaning. The timesheet function is the commercial timesheet on top of it — suggestions, reports, productivity, absences, and the door that lets a plugin add hours.

const hours = ctx.funzioni.has("timesheet");   // show the hours column?

To refuse to start without something, say it in the manifest (richiede, see editions) instead of checking here.

The user

ctx.sessionUser(req) returns:

{
  id: "ckx…",            // User.id
  name: "Anna",          // the nickname, or the full name
  role: "MEMBER",        // "ADMIN" | "MEMBER"
  elevated: false,       // an administrator who has elevated their session («super admin»)
  canViewAllTimesheets: false,
  locale: "it",          // the language chosen in KeelOps, or null
}

Only internal people reach a plugin: customers of the portal and sales monitors get null. An administrator is role: "ADMIN"; the powerful actions of KeelOps require elevated: true too — follow the same rule for yours.

What create() returns

fieldtypemeaning
routesarray[method, regexp, handler(req, res, match, url)], tried in order. The path is relative to the plugin.
staticDirstringA folder served as it is for GET requests no route matched; / serves index.html.
wellKnownstring[]OAuth documents (oauth-authorization-server, …) to also expose at the root in the path insertion form of RFC 8414: /.well-known/<doc>/plugins/<name>.
migrateasync (db, from)Brings the plugin's tables from version from to manifest.schemaVersion. Required when the plugin has a nick and a schemaVersion > 0.
strumentiarrayTools the plugin offers to other plugins. →
riepilogoMattutinoasync ({ oggi })Lines for the morning digest email. →

Routes

routes: [
  ["GET",  /^\/api\/items$/,        list],
  ["GET",  /^\/api\/items\/([^/]+)$/, detail],     // match[1] is the id
  ["POST", /^\/api\/items$/,        create],
],
  • The dispatcher tries the routes in order and stops at the first whose method and regexp match. Then the static folder, then a 404.
  • url is a URL: read the query with url.searchParams.get("q").
  • Bodies arrive untouched: the core does not parse them. Read them with readBody(req) or readJson(req) from http.mjs (1 MB limit by default; pass your own for uploads).
  • An exception in a handler becomes a 500 { errore: "errore interno" } and one line in the log with the method, the path and the message — never the body. Answer errors yourself for the ones the person should read.

A pattern that keeps handlers short — authenticate once, turn thrown HttpErrors into answers:

class HttpError extends Error {
  constructor(status, message) { super(message); this.status = status; }
}

const guarded = (handler) => async (req, res, match, url) => {
  try {
    const user = await ctx.sessionUser(req);
    if (!user) return json(res, 401, { error: "a KeelOps session is required" });
    await handler(user, req, res, match, url);
  } catch (err) {
    if (err instanceof HttpError) return json(res, err.status, { error: err.message });
    throw err;   // the dispatcher logs it and answers 500
  }
};

routes: [["GET", /^\/api\/items$/, guarded(async (user, req, res) => json(res, 200, await list(user)))]];

One process, one lifetime

create runs once. A plugin that starts a timer (a nightly job, a cache refresh) keeps it for the life of the process: call .unref() on it, so it never keeps the server alive on shutdown. A plugin switched off from the System page stops answering at once, but its timers stop only at the next restart — check at the top of a timer whether there is still work to do.