Extensions

Extensions

Cronable ships 28 built-in job types, and its whole job model is an extension model: a job type is a small, self-contained folder the daemon loads. You can add your own job types the same way — without rebuilding the product. Everything the UI does for a type (the Inspector form, the DAG node, the palette entry, the Simple-mode wizard, the translations, the YAML round-trip) is derived from the type’s manifest — there’s no per-type UI code to write.

This page is the developer guide to authoring a job type: the generator, the folder layout, the manifest, the executor, capabilities, translations, and how a finished extension is signed and loaded.

A job type = one folder. A manifest.ts (the schema + declarative UI fields + display metadata, shared by daemon and browser), an executor.ts (daemon-only: how it runs), an optional source.ts (for a polling trigger), and a co-located i18n.json (translations). Add the folder, register it in the barrels, install it. Nothing else changes.


Start with the generator

Adding a type by hand touches a dozen places across three barrel files — miss one and the type half-exists (validates but has no UI, or renders but never runs). The generator writes the folder and makes every barrel edit for you:

pnpm create-extension <id> --role action|operator|trigger
  • <id> — a lowercase kebab slug (a-z, 0-9, -), used as the schema discriminator, the YAML type, and the folder name.
  • --roleaction (the default), operator, or trigger. See Roles below.

It scaffolds packages/extensions/src/job-types/<id>/ with a valid manifest.ts (with a wizard stub for actions), an executor.ts stub, an i18n.json skeleton for the seven non-English locales, and — for a trigger — a source.ts stub. Then it wires the barrels (manifests.ts, executors.ts, and sources.ts for triggers). The result compiles and passes the invariants test the moment it lands; you fill in the real fields and logic. The printed next steps remind you to pick a real icon and translate i18n.json.


The folder layout

my-extension/
  manifest.ts    ISOMORPHIC — schema + declarative fields + display metadata (daemon AND browser)
  executor.ts    DAEMON-ONLY — how the job runs
  source.ts      triggers only (role: 'trigger') — how the poller discovers new items
  i18n.json      co-located translations (imported into the manifest)

The kernel SDK you build against lives in packages/extensions/src/kernel/manifest.ts (JobTypeManifest), field.ts (FieldSpec + the credentialField() helper), executor.ts (the Executor contract + ExecContext), source.ts (the TriggerSource contract), and base.ts (the shared jobBaseShape). None of it imports Node or React, so the manifest is safe to ship to the browser.


Roles: actions, operators, triggers

Every type declares a role (action is the default):

  • action — does real work when fired: on a schedule, from a parent, or manually (e.g. claude, http, email).
  • operator — shapes the chain rather than doing external work (wait parks it, switch/filter route it). This is a display/grouping hint only — operators schedule and chain exactly like actions; their parking/routing behavior comes from capabilities, not the role.
  • trigger — always a pipeline root. It’s fired by its own source (an inbound webhook call or a poller) and may have no schedule points — no time points and never an afterParent child. A trigger enforces this by pinning schedule: triggerScheduleSchema in its schema, so the loader, the API, and the web client all reject a violating job identically. The Inspector hides the schedule builder for triggers. See the Triggers guide.

The manifest

A JobTypeManifest is the single source of truth for a type’s schema, its fields (the Inspector renders them generically), and its display metadata.

export interface JobTypeManifest {
  kind: 'job-type';
  id: string; // the discriminator — also the YAML `type`
  version: string; // shown in Settings → Extensions
  meta: ExtensionMeta; // label, icon name, blurb, optional Simple-mode wording
  schema: z.ZodObject; // the per-type zod schema (base shape merged in)
  fields: FieldSpec[]; // declarative UI + data-flow, in render order
  notes?: string[]; // optional helper prose under the fields
  role?: 'action' | 'operator' | 'trigger'; // default 'action'
  capabilities?: JobTypeCapabilities; // optional pure contracts (park / routing / inbound)
  wizard?: WizardSpec; // optional guided Simple-mode flow
  i18n?: ExtensionI18n; // co-located translations
}

The schema

Spread jobBaseShape (which provides id, name, group, enabled, schedule, env, retry, onFailure, timezone, and friends) into a z.object, add a type: z.literal('<id>') discriminant, then your own fields:

export const slackJobSchema = z.object({
  ...jobBaseShape,
  type: z.literal('slack'),
  webhookUrl: z.string().min(1),
  text: z.string().min(1),
});

Every schema-required field needs a schema-valid default. The New-job dialog (and a type switch) scaffold a job from field defaults and submit it through the schema — a required (.min(1)) field with an empty or missing default makes the type impossible to create from the UI. Use an obviously-placeholder-but-valid value that fails a run loudly rather than silently (https://example.com, you@example.com, SELECT 1, a credential name like prod_db). Leave default off optional fields — seeding '' writes an empty string into the YAML, which is not the same as omitting the key.

meta

meta: {
  label: 'Claude (CLI)',
  icon: 'Bot', // a lucide-react icon NAME (a string, not a component)
  blurb: 'Run a headless Claude Code CLI agent in a repo',
  simple: { label: 'Give Claude a task', blurb: 'Let Claude read and edit a project' },
}

The icon is a name string so the manifest never pulls React into the daemon; the web maps it to a lucide component. meta.simple gives the type a friendlier name/one-liner in Simple mode (the palette and the wizard); missing keys fall back to label/blurb.


Fields — FieldSpec

Each editable field is one FieldSpec. The generic Inspector form renders it by kind; the YAML writer and the expression dry-render derive their behavior from the same list.

interface FieldSpec {
  name: string; // must match a key on the schema
  label: string;
  kind: FieldKind;
  hint?: string;
  placeholder?: string;
  rows?: number; // textarea / code
  optional?: boolean;
  options?: { value: string; label: string }[]; // select
  suggestions?: string[]; // text → a <datalist>
  default?: unknown; // seeds a new node + carries across a type switch
  expression?: boolean; // {{ }}-aware → live preview + macros badge + dry-render
  showIf?: { field: string; equals: unknown }; // conditional (e.g. body ⇐ method POST)
  modes?: ('simple' | 'advanced')[]; // which UI modes show it; absent = both
  simple?: { label?: string; hint?: string; placeholder?: string }; // friendlier Simple-mode wording
  control?: string; // kind:'custom' → key into the web control registry
  controlConfig?: Record<string, unknown>; // kind:'custom' → opaque config for that control
}
kindControl renderedNotes
textsingle-line input (expression-aware if expression)suggestions → datalist
textareamacro textarea ({} badge + Expand + Preview)set expression: true for {{ }}
codemonospace textarea, no {} badge/previewraw script; Expand still available
selectdropdown from options
numbernumeric input
keyValuea Record<string,string> editorvalues expression-aware if expression
cwdpath input + server-side folder picker
readonlydisplay-only inpute.g. a webhook hookId
customa component from the web control registry (control)unknown key → plain text-input fallback

Any field with expression: true automatically gets the in-field {} macros badge (inserts a {{ }} macro at the cursor), a live Preview toggle, and inclusion in the daemon’s dry-render — no per-field wiring.

Simple / Advanced tiers

Two optional keys tailor a field to the interface modes (see Simple & Advanced modes):

  • modes — which modes render the field. Absent = both. Mark advanced-only detail ['advanced'] so Simple mode stays uncluttered (a timeout override, a raw-header editor); a Simple-only helper is ['simple']. The generic form and the wizard filter on this.
  • simple — friendlier label/hint/placeholder shown only in Simple mode; Advanced uses the base wording, and any omitted key falls back to the base.
{
  name: 'instructions',
  label: 'Instructions',              // Advanced wording
  kind: 'textarea',
  rows: 6,
  expression: true,
  default: 'echo "hello from cronable"',
  simple: { label: 'What should Claude do?' },  // Simple wording
}
// An advanced-only knob, hidden from Simple mode entirely:
{ name: 'timeoutMs', label: 'Timeout (ms)', kind: 'number', optional: true, modes: ['advanced'] }

Credential pickers — credentialField()

A type that talks to a service takes a credential by name. Instead of a hand-rolled text box, use the credentialField() helper from the kernel — it builds a kind: 'custom' picker over the stored credentials (names only; values never reach the browser), filtered to one kind. A web build without the control degrades to a plain text input, and the stored value stays a plain string name (no schema change):

credentialField({
  name: 'authCredential', // the schema field (defaults to 'credential')
  kindField: 'authScheme', // DYNAMIC: a sibling field whose value IS the kind
  // or: kind: 'slack',    // STATIC: a fixed kind from CREDENTIAL_KINDS
  optional: true,
  modes: ['advanced'],
  hint: 'a stored credential (Settings → Secrets → Credentials)',
})

Filter dynamically via kindField (naming a sibling field whose value is 1:1 a credential kind — the http job’s authScheme, the database job’s engine) or statically via kind. With both, the sibling value wins and kind is the fallback.


The guided wizard (optional)

wizard declares a guided-creation flow for Simple mode. Each step lists field names (from manifest.fields) to collect, in order; the web renders each step large, reusing the same generic field components (so a wizard field honors its simple wording and modes).

wizard: {
  steps: [
    { title: 'What should Claude do?', prompt: 'Describe the task in plain words.', fields: ['instructions'] },
    { title: 'Which folder?', prompt: 'Pick the project Claude should work in.', fields: ['cwd'], cta: 'Create job' },
  ],
}

Absent ⟹ the web builds a generic wizard from the type’s Simple-tier fields, so a type gets a usable wizard even without declaring one; wizard just lets you curate the steps and copy.


The executor (daemon-only)

An executor implements the Executor<J> contract. Its ExecContext is host-bound — the daemon injects expression rendering, the resolved secret/credential maps, the masked log sink, the run’s timeoutMs, and a cancel signal — so an executor never imports daemon internals:

interface Executor<J> {
  readonly type: string;
  run(job: J, ctx: ExecContext): Promise<ExecResult>;
}
 
interface ExecContext {
  runId: string;
  render(value: string): string; // render one {{ }} field
  renderRecord(r: Record<string, string> | undefined): Record<string, string>;
  evaluateScript(script: string): unknown; // the vm sandbox (the code job)
  secrets: Record<string, string>; // resolved, already in the masking set
  credentials: Record<string, Record<string, string>>; // resolved credential fields, masked
  env: NodeJS.ProcessEnv; // merged child env
  log: { note(t: string): void; write(s: 'stdout' | 'stderr', c: string): void };
  timeoutMs: number; // the run's hard timeout — YOUR executor must enforce it (see below)
  signal: AbortSignal; // aborts on CANCEL only (not timeout) — pass to fetch/child
  onPid?(pid: number): void;
  payload?: TriggerPayload; // webhook-triggered runs only
  parentOutput?: RunOutput | null; // parent's persisted (masked) output
}

Return an ExecResult — a state (succeeded | failed | timed_out | cancelled), an output (RunOutput: text / json / status), and an optional error. Never mask by hand — the run-manager masks the log and the returned output for you.

Honoring cancel and the run timeout

ctx.signal and ctx.timeoutMs are two separate obligations, and your executor owns both:

  • Cancelctx.signal aborts when the user cancels the run. Pass it to your fetch, or kill your child’s process group when it fires, so a cancel stops the work promptly and you settle { state: 'cancelled' }.
  • Timeout — there is no outer run-timeout, and ctx.signal does not fire on timeoutMs. Your executor (or the helper it runs through) must enforce ctx.timeoutMs itself and settle { state: 'timed_out' } on expiry.

A spawning executor gets this for free from the built-in spawn helper: it spawns the child detached (its own process group) and arms a timer that SIGTERMs — then SIGKILLs — the group on timeoutMs, while a cancel on ctx.signal kills the group too, returning the right state either way. A fetch-based executor must arm its own timer — the http executor is the reference:

// one controller that BOTH a cancel and our own timer can abort
const controller = new AbortController();
const onAbort = () => controller.abort();
if (ctx.signal.aborted) controller.abort();
else ctx.signal.addEventListener('abort', onAbort, { once: true });
 
let timedOut = false;
let timer: NodeJS.Timeout | undefined;
if (Number.isFinite(ctx.timeoutMs) && ctx.timeoutMs > 0) {
  timer = setTimeout(() => {
    timedOut = true;
    controller.abort();
  }, ctx.timeoutMs);
}
try {
  const res = await fetch(url, { signal: controller.signal }); // controller.signal, NOT ctx.signal
  // read the body INSIDE this try, so a timeout firing mid-read is caught too
  return { state: 'succeeded', output: { status: res.status } };
} catch (err) {
  // your timer aborted the SAME controller, so both look like an abort — the flag disambiguates,
  // and timedOut is checked first so a timeout is never mislabelled a cancel.
  if (timedOut)
    return { state: 'timed_out', error: null, output: { text: `timed out after ${ctx.timeoutMs}ms` } };
  if (ctx.signal.aborted) return { state: 'cancelled', output: {} };
  return { state: 'failed', error: String(err), output: {} };
} finally {
  if (timer) clearTimeout(timer);
  ctx.signal.removeEventListener('abort', onAbort); // always remove it — a stray listener leaks past the run
}

Keep error: null for timed_out/cancelled — the reason lives in the state, not the error.


Trigger sources (polling triggers only)

A polling trigger ships a third file, source.ts, implementing TriggerSource. The daemon’s poller calls poll() on the job’s interval; every returned item enqueues one run of the trigger with the item as its payload — jobs chained after it read it via {{ $parent.json.* }} / {{ $parent.text }}.

interface PollItem {
  key: string; // stable dedup key, persisted per job across restarts
  detail: string; // run-history trigger detail, e.g. 'mail from x: subject'
  json: unknown; // → the trigger run's output.json
  text: string; // → the trigger run's output.text
}
 
interface TriggerSource<J> {
  readonly type: string;
  poll(job: J, ctx: SourceCtx): Promise<PollItem[]>;
}

The host owns dedup (ctx.seen/ctx.markSeen), enqueueing, per-tick timeouts, and error backoff — a source only discovers, with no side effects beyond ctx state. Everything polls outward (IMAP, filesystem, HTTPS), so a trigger works on a host with no inbound network surface. The push-style webhook trigger predates this SDK and keeps its own inbound route — it has no source.


Capabilities (optional, pure contracts)

A type can declare small, pure, side-effect-free functions the daemon consumes generically — daemon core never checks job.type === '…', it only consults the contract. They live on the manifest (and ship to the browser, so keep them tiny):

CapabilityDeclared byWhat it does
park(job)waitThe run enters the durable waiting state for the returned ms instead of executing — no concurrency slot, restart-safe.
branchOf(output)switchWhich branch did a settled run select? Branch-labelled chain edges fire only when this matches.
branchLabels(job)switch/filterThe branch names a specific job can route into — the edge editor lists them.
defaultBranch(job)filterThe branch an afterParent edge to this job defaults to when first drawn (filter'pass').
inboundTrigger: truewebhookThe daemon provisions/retires a per-job inbound endpoint on reconcile (the push-trigger path).

Translations — a co-located i18n.json

Each extension ships its own translations next to its manifest as i18n.json, imported onto the manifest as i18n. The supported locales are en de ru es it fr pt zh — the same eight languages as the app. The English text inline on the manifest is the guaranteed per-key fallback, so a string missing from any locale renders in English and a partial translation is never broken (English need only carry the Simple-mode _simple variants).

Keys are flat, dotted, and mirror the manifest:

Dotted keyTranslates
meta.label / meta.blurbthe type name / one-liner
meta.label_simple / meta.blurb_simplethe Simple-mode type name / blurb
fields.<name>.label / .hint / .placeholderthat field’s Advanced wording
fields.<name>.label_simple / .hint_simple / .placeholder_simplethat field’s Simple wording
notes.<i>the i-th entry of notes[]
wizard.steps.<i>.title / .prompt / .ctathe i-th wizard step’s copy
// job-types/slack/i18n.json  →  imported as `i18n` in manifest.ts
{
  "de": {
    "meta.label": "Slack",
    "meta.blurb": "Eine Nachricht an Slack senden",
    "fields.text.label": "Nachricht",
    "wizard.steps.0.title": "Was soll gesendet werden?"
  },
  "es": { "meta.blurb": "Enviar un mensaje a Slack", "fields.text.label": "Mensaje" }
}

A worked example — a minimal slack action

Three files plus two barrel edits (all of which pnpm create-extension slack writes for you). The manifest:

// job-types/slack/manifest.ts
import { z } from 'zod';
import { jobBaseShape } from '../../kernel/base';
import type { JobTypeManifest } from '../../kernel/manifest';
import i18n from './i18n.json';
 
export const slackJobSchema = z.object({
  ...jobBaseShape,
  type: z.literal('slack'),
  webhookUrl: z.string().min(1),
  text: z.string().min(1),
});
export type SlackJob = z.infer<typeof slackJobSchema>;
 
export const slackManifest: JobTypeManifest = {
  kind: 'job-type',
  id: 'slack',
  version: '1.0.0',
  meta: {
    label: 'Slack',
    icon: 'MessageSquare',
    blurb: 'Post a message to Slack',
    simple: { label: 'Send a Slack message' },
  },
  schema: slackJobSchema,
  fields: [
    // Both fields are required (min(1)) → defaults MUST be non-empty, or the type can't be created.
    {
      name: 'webhookUrl',
      label: 'Webhook URL',
      kind: 'text',
      expression: true,
      default: 'https://example.com/hook',
      modes: ['advanced'],
    },
    {
      name: 'text',
      label: 'Message',
      kind: 'textarea',
      rows: 4,
      expression: true,
      default: 'Job {{ $parent.name }} ran',
      simple: { label: 'What to say' },
    },
  ],
  wizard: {
    steps: [{ title: 'What to say?', fields: ['text'], cta: 'Create job' }],
  },
  i18n,
};

The executor:

// job-types/slack/executor.ts
import type { SlackJob } from './manifest';
import type { ExecContext, ExecResult, Executor } from '../../kernel/executor';
 
export const slackExecutor: Executor<SlackJob> = {
  type: 'slack',
  async run(job: SlackJob, ctx: ExecContext): Promise<ExecResult> {
    const url = ctx.render(job.webhookUrl);
    const text = ctx.render(job.text);
    ctx.log.note(`[cronable] slack → ${url}`);
    const res = await fetch(url, {
      method: 'POST',
      body: JSON.stringify({ text }),
      signal: ctx.signal, // abort on cancel/timeout
    });
    const ok = res.status >= 200 && res.status < 300;
    return {
      state: ok ? 'succeeded' : 'failed',
      output: { status: res.status, text: `slack ${res.status}` },
      error: ok ? null : `slack responded ${res.status}`,
    };
  },
};

A small i18n.json (English stays inline on the manifest as the fallback):

{
  "de": { "meta.label": "Slack", "meta.blurb": "Eine Nachricht an Slack senden", "fields.text.label": "Nachricht" },
  "es": { "meta.blurb": "Enviar un mensaje a Slack", "fields.text.label": "Mensaje" }
}

Then translate the remaining locales, pick a real lucide icon, and you’re done — the Inspector form, the DAG node, the palette, the wizard, and the YAML round-trip all follow from the manifest.


Verify with the invariants test

The manifest-invariants test is the correctness net — it checks that every field.name is a real schema key, that required fields carry valid defaults, that wizard steps name real fields, and more. Run it (plus the type-checker) before you ship:

pnpm --filter @cronable/daemon test
pnpm -r typecheck
pnpm format

Where extensions load from

The daemon scans the extensions directory at boot; each subfolder is one package:

  • Default: <CRONABLE_ROOT>/data/extensions/ — for a cronable install, that’s ~/.cronable/state/data/extensions/.
  • Override with CRONABLE_EXTENSIONS_DIR=/path/to/extensions.

The directory is daemon-owned and owner-only, and code loads from the filesystem only — there is no “upload extension” endpoint or button, because loading an extension is arbitrary code execution inside the daemon (the same trust as a terminal job). Installing one is a filesystem operation you do on the box, then restart the service.

A runtime extension is authored exactly like a built-in (same JobTypeManifest, Executor, FieldSpec, TriggerSource), then compiled to .js — a built package holds manifest.js, executor.js, source.js (triggers only), plus two signature sidecars.

Signed vs. your own

By default the daemon loads only vendor-signed, license-entitled extensions. Before importing any code, the loader runs three gates and skips (with a logged reason) any package that fails — a bad package can never crash boot:

  1. Structure — the entry must be a real directory inside the scanned dir; symlinks and .. traversal are rejected.
  2. Signature + hashes + entitlement — an Ed25519 signature over the signed manifest verifies against the embedded vendor key; every listed file’s sha256 must match (and no stray .js may sit unlisted); and the package id must be in your license’s entitlements.
  3. Import + register — only verified members are imported; an id that collides with a built-in is refused.

To load your own unsigned extensions on your own box, set CRONABLE_ALLOW_UNSIGNED_EXTENSIONS=true. The loader then loads unsigned local packages — skipping the signature + entitlement gate (file hashes and the structure gate still apply) — and prints a loud one-time warning that unsigned local code is being executed (same trust as a terminal job). The default is false (signed-only).

After adding an extension, restart the service (cronable stop && cronable start); it appears in the dashboard’s node palette, and its jobs validate and run like any built-in’s.

A runtime type gets its Inspector form, Simple mode, wizard, and translations from served manifest data — the daemon serves the manifest’s pure data (meta, fields, notes, role, wizard, i18n), never the zod schema (validation stays server-side) or any executor code. So a shared/hosted dashboard renders a form for a type it was never compiled with. A bespoke React control or a brand-new icon the shipped web build doesn’t know still needs a web build — an unknown control key degrades to a plain text input and an unknown icon falls back to the default, so the form never crashes; it’s just not custom-rendered.