Tools server contract
The contract every tool's server package must follow. Reference implementation: tools/tool_dev_template/server/index.ts (+ its handlers). Machinery: src/core/tools/{module,dispatch,security,loader,paths,config,register,background,cache}.ts. Concept spec: engineering/TOOLS_SPEC.md.
A tool's server module is discovered once by an allowlisted directory scan (loader.ts), and its actions are typed functions keyed in a plain object (apiActions) — "the method exists and is callable" is a Map/Object.hasOwn lookup, not a reflection check. There is no base class and no autoloader: dispatch runs an ordered gate chain (registry → per-user authorization → loaded module → apiActions lookup → declarative permission → execute) entirely against typed data.
The module
- File
server/index.tsin the tool root — never served to the browser (serving.tsrefuses the wholeserver/subtree). - Exports
const tool: ToolServerModule(src/core/tools/module.ts):
interface ToolServerModule {
name: string; // must equal the directory name, ^tool_[a-z0-9_]+$
apiActions: Record<string, ToolActionSpec>; // the remote surface
backgroundRunnable?: readonly string[]; // second allowlist for async execution
isAvailable?: (context) => boolean | Promise<boolean>; // toolbar availability
onRegister?: () => Promise<void>; // lifecycle hook — NEVER inside apiActions
onRemove?: () => Promise<void>; // lifecycle hook — NEVER inside apiActions
}
- The loader validates this contract at scan time (
loader.ts::validateModule):tool.namemust equal the directory name and match the tool-name pattern;apiActionsmust be an object; none of the reserved lifecycle keys (isAvailable/onRegister/onRemove) may appear inside it; every action'shandlermust be a function. A tool that fails validation logs a warning and is simply absent from the registry — it never aborts the whole scan. - Server logic imports
src/core/**via relative paths. Core never statically imports a tool (the dependency points tool → core, never the reverse).
Remotely callable methods (API actions)
Every entry in apiActions is a ToolActionSpec:
interface ToolActionSpec {
permission: 'section' | 'section_list' | 'targets' | 'tipo' | 'record' | 'record_tipo' | 'developer' | null;
minLevel?: number; // dd774 level: 1=read, 2=write (default), 3=admin
sectionTipos?: (options) => unknown[]; // REQUIRED for 'section_list'
targets?: (options) => WriteTarget[]; // REQUIRED for 'targets'
handler: (context: ToolActionContext) => Promise<ToolResponse>;
}
interface WriteTarget { section_tipo: unknown; tipo?: unknown; section_id?: unknown }
handler receives { principal, userId, options, background, publishProgress?, clientIp? } and returns a ToolResponse, which replaces the API envelope wholesale. So a ToolResponse is the API envelope: build it with ok(data, …), and add the extra top-level fields the client reads by name (a streaming body, a job id, a per-item report) as extend.
To fail, throw — never build a failure body. A thrown DedaloError carries a registered code from the closed set, and the tools dispatcher converts it into the standard error envelope with the status that code's category implies. There is no failed() helper and no 'Error. Request failed.' prefix any more.
handler: async ({ options, principal }) => {
const target = String(options.target ?? '');
if (target === '') throw new DedaloError('tool.invalid_target'); // → 400
const report = await runTheWork(target, principal); // may throw its own code
return ok({ processed: report.done, failures: report.failures }, { extend: { job_id: report.jobId } });
}
Note where the per-item failures went: inside data. A run that processed 117 records and could not process 3 is a SUCCESS with a report — ok: false is reserved for "this run did not happen". The same rule as elsewhere in the engine: ok describes the request, not the quality of every row it touched. See the API reference for the envelope's full field list.
Two of those fields are optional and carry a caveat:
| field | when it is present |
|---|---|
publishProgress |
Background execution only — a foreground call has no job record to publish into. |
clientIp |
The proxy-validated client address, for an action that appends an activity row. Under background execution it is the value captured at submit time: the job outlives the request that started it, so there is no live socket to ask. |
Pass clientIp through hostFromClientIp rather than deriving the host rule yourself.
The request envelope
The client sends this (built by the JS helper this.tool_request()):
{
"dd_api": "dd_tools_api",
"action": "tool_request",
"source": { "model": "tool_x", "action": "my_method", "...": "..." },
"options": { "section_tipo": "oh1", "section_id": 5, "...": "..." }
}
dispatchToolRequest (src/core/tools/dispatch.ts, called from dd_tools_api.tool_request in src/core/api/dispatch.ts) runs this gate chain, in order:
optionsmust be an object (or absent);- the tool name must match
^tool_[a-z0-9_]+$— rejected before any lookup; -
-
- the tool must be ACTIVE in dd1324 and authorized for the calling user (
getUserToolsinregistry.ts: admins get every active tool; others the profile-granted dd1067 set +always_activedd1601 tools);
- the tool must be ACTIVE in dd1324 and authorized for the calling user (
-
- the tool must have a loaded server module (
getLoadedTool); - the method must be a key of the module's
apiActions(resolveAction, the allowlist lookup); - the action's declarative permission gate must pass (
assertActionPermission) — before any background fork; - execute: directly, or (when
options.background_running === true) viascheduleBackground, which additionally enforces thebackgroundRunnableallowlist.
Permission kinds (src/core/tools/security.ts)
permission |
Reads from options |
Asserts |
|---|---|---|
section |
section_tipo |
permission level ≥ minLevel on (section_tipo, section_tipo) |
tipo |
section_tipo + tipo |
permission level ≥ minLevel on (section_tipo, tipo) |
record |
section_tipo + numeric section_id |
the tipo-equivalent section-level check plus the record must be inside the caller's project scope (global admins skip this) |
record_tipo |
section_tipo + tipo (alias component_tipo) + numeric section_id |
the (section_tipo, tipo) PAIR plus the record scope — the gate for a component OF a record |
section_list |
whatever sectionTipos(options) returns |
level ≥ minLevel on every returned section; an empty list or an invalid entry is a denial |
targets |
whatever targets(options) returns |
level ≥ minLevel on every (section_tipo, tipo?) — the PAIR when tipo is named — and, when section_id is named, a positive record inside the caller's scope; an empty list, a malformed entry or a throwing extractor is a denial |
developer |
— | principal.isDeveloper |
null |
— | always passes here — the handler gates imperatively (defense in depth), e.g. tool_export's get_export_grid (which must additionally assert read on every SQO target the grid touches, something the declarative gate cannot express) |
minLevel defaults to 2 (write) when omitted. A missing or ill-typed required option field (e.g. no section_tipo for a tipo gate) is a fail-closed denial, never a pass — the request never reaches the handler. The dispatcher enforces the declarative spec before the handler runs.
Declare the gate on the target the action WRITES. When the effect target is not a top-level option — the scope rides in options.sqo (tool_update_cache::update_cache re-saves the selected components on every matched row), in a nested client map (tool_import_files writes into every tool_config.ddo_map destination), or the handler pins a section by constant (tool_hierarchy writes hierarchy1/<section_id> whatever section_tipo arrives) — use targets and derive the write targets off the same keys the handler reads. A section/tipo gate on a sibling field authorizes something the action never touches and leaves what it does touch ungated. A target the handler can only resolve at run time — an ontology-derived portal section, or a RECORD it binds while running (a filename prefix, a matcher hit, a role write's destination) — is authorized inside the handler at the point it is bound, before the first write into it, through the save door's own record-scope rule (assertRecordWriteTarget); a record created in the same run is admitted as a create is. test/unit/action_scope_binding_tripwire.test.ts binds every such handler to its extractor; permission: null remains the named exemption for an action no declarative kind can express, and it must say in gatedInHandler what the handler does instead.
Never list lifecycle hooks
isAvailable, onRegister and onRemove are called by the framework, not remotely. loader.ts throws (refusing to load the tool) if any of them appears as a key of apiActions.
Background execution
Long-running actions can run detached: the client passes options.background_running = true. Bun's server is a persistent process, so scheduleBackground (src/core/tools/background.ts) runs the handler as a fire-and-forget promise plus an in-process job record — it returns an ok envelope with the job id as an extension key immediately and runs the handler afterwards, capturing the outcome on the job record (getBackgroundJob(id)).
The method must ALSO be listed in the module's backgroundRunnable:
backgroundRunnable: ['my_long_method'],
The declarative permission gate already ran (step 7 above) before the background fork, so unauthorized callers are refused observably, not silently queued. The background executor does not re-run the per-action gate; keep imperative asserts inside long-running write handlers as defense in depth (see tool_propagate_component_data's handler, which re-derives its own gate because the target is SQO-wide, not a single record).
Ledgered (engineering/TOOLS_SPEC.md)
Background jobs die on server restart — the in-process job table does not survive a Bun restart — and a CPU-bound handler currently shares the event loop with every other request. A Bun Worker-based executor is a drop-in follow-up behind the same scheduleBackground signature.
Configuration (src/core/tools/config.ts)
Three storage points, one accessor set:
| Where | What |
|---|---|
dd1324 / default_config (component dd1633) |
factory defaults shipped by the tool's register.json |
| dd996 "Tools configuration" section (component dd999) | per-install overrides, edited by admins |
properties (register.json) |
UI hints (open_as, windowFeatures, events) |
Resolution helpers:
getToolConfig(toolName)— the whole effective config object; install value wins per key over the register default.getToolConfigValue(toolName, key, fallback)— per-key precedence: install (dd996/dd999) → register default (dd1324/dd1633) → the caller-suppliedfallback. Preferred for single keys.getToolClientConfig(toolName)/getToolClientConfigRaw(toolName)— only options flagged"client": truein either layer, resolved to their effective value (ClientConfig) or kept as the full prop definition (ClientConfigRaw, used by the tool element context). Everything else never reaches the browser — never put secrets in aclient: trueproperty.
invalidateAllToolCaches() (src/core/tools/cache.ts) is the single entry point clearing the registry reader, both config caches, the paths memo and the loaded-tools registry; call it (or trigger the "Register tools" widget) after any dd1324/dd996/dd234 write.
Lifecycle hooks (optional module properties)
| Hook | Signature | Called |
|---|---|---|
isAvailable |
(context: ToolAvailabilityContext) => boolean \| Promise<boolean> |
by the section/component tool filter (getElementTools in registry.ts) after the affected_models/affected_tipos match, with {callerModel, tipo, sectionTipo, isComponent, mode}. Return false to hide the tool for that element. Must be fast and side-effect-free — results are cached per user/tipo/section. Tools without a loaded module fall back to a small set of core rules (tool_diffusion's section-only + diffusion-map check is the one still resolved in registry.ts today). |
onRegister |
() => Promise<void> |
after the registry record is reconciled during importTools(). Sanctioned place for setup (e.g. seeding a dd996 config record). A throw is logged, never fails the import. |
onRemove |
() => Promise<void> |
best-effort, before the registry record of a removed tool is deleted. |
Registration-time validation (src/core/tools/register.ts, register_schema.ts)
importTools({dryRun}) scans the roots, parses each register.json, detects its format, and validates it:
- top-level
componentskey → legacy v6 dump — not supported this wave (none of the 34 in-repo tools use it, so this has not blocked any real port); - top-level
namekey → the flat authoring format (authoringRegisterSchema, a Zod mirror ofsrc/core/tools/register.schema.json) — converted to the column-keyed shape; - column-keyed (
data/string/relation/…) → pass-through, validated as-is. All 34 in-reporegister.jsonfiles are this form — they are seeded matrix-row dumps, not hand-authored files.
Write gating. importTools defaults to dry-run (config.tools.enableRegistryImport = false): for every tool it reports whether the registry already reflects the declared identity (empty diff = no-op), writing nothing. The write path (enableRegistryImport = true) is gated behind the write-parity procedure in engineering/TOOLS_SPEC.md (a test/parity/tools_register_differential.test.ts no-op gate plus one manual scratch-DB write-parity run) before it may be documented as supported.
A missing/invalid apiActions shape, a tool.name that does not match the directory, or a lifecycle hook listed inside apiActions all fail the loader's validateModule check (logged, tool absent from the registry) — there is no silent partial registration.
Multi-root resolution (src/core/tools/paths.ts)
All path/URL resolution goes through getRoots() / resolveToolRoot() / getToolUrl(): index 0 is always the in-repo tools/ root; extra roots come from config.tools.additionalRoots (env DEDALO_ADDITIONAL_TOOLS, JSON [{path,url}]), each canonicalized and refused if missing, not a directory, or a system temp dir. First-root-wins name collisions are reported via getToolLoadCollisions(), never silently overridden. Never build a tool path/URL from a raw config value in new code — always go through these helpers so additional-root tools resolve to their own URL and the client's DEDALO_TOOLS_URLS map stays in lockstep.