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), anexecutor.ts(daemon-only: how it runs), an optionalsource.ts(for a polling trigger), and a co-locatedi18n.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 YAMLtype, and the folder name.--role—action(the default),operator, ortrigger. 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 (waitparks it,switch/filterroute 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 anafterParentchild. A trigger enforces this by pinningschedule: triggerScheduleSchemain 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 likeprod_db). Leavedefaultoff 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
}kind | Control rendered | Notes |
|---|---|---|
text | single-line input (expression-aware if expression) | suggestions → datalist |
textarea | macro textarea ({} badge + Expand + Preview) | set expression: true for {{ }} |
code | monospace textarea, no {} badge/preview | raw script; Expand still available |
select | dropdown from options | |
number | numeric input | |
keyValue | a Record<string,string> editor | values expression-aware if expression |
cwd | path input + server-side folder picker | |
readonly | display-only input | e.g. a webhook hookId |
custom | a 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— friendlierlabel/hint/placeholdershown 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:
- Cancel —
ctx.signalaborts when the user cancels the run. Pass it to yourfetch, 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.signaldoes not fire ontimeoutMs. Your executor (or the helper it runs through) must enforcectx.timeoutMsitself 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):
| Capability | Declared by | What it does |
|---|---|---|
park(job) | wait | The run enters the durable waiting state for the returned ms instead of executing — no concurrency slot, restart-safe. |
branchOf(output) | switch | Which branch did a settled run select? Branch-labelled chain edges fire only when this matches. |
branchLabels(job) | switch/filter | The branch names a specific job can route into — the edge editor lists them. |
defaultBranch(job) | filter | The branch an afterParent edge to this job defaults to when first drawn (filter → 'pass'). |
inboundTrigger: true | webhook | The 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 key | Translates |
|---|---|
meta.label / meta.blurb | the type name / one-liner |
meta.label_simple / meta.blurb_simple | the Simple-mode type name / blurb |
fields.<name>.label / .hint / .placeholder | that field’s Advanced wording |
fields.<name>.label_simple / .hint_simple / .placeholder_simple | that field’s Simple wording |
notes.<i> | the i-th entry of notes[] |
wizard.steps.<i>.title / .prompt / .cta | the 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 formatWhere extensions load from
The daemon scans the extensions directory at boot; each subfolder is one package:
- Default:
<CRONABLE_ROOT>/data/extensions/— for acronable 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:
- Structure — the entry must be a real directory inside the scanned dir; symlinks and
..traversal are rejected. - 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
.jsmay sit unlisted); and the packageidmust be in your license’s entitlements. - 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 zodschema(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 unknowncontrolkey 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.