Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Workflows today are written **for** agents, not **by** them. Visual canvas tools
- **Approval gates** — `do: wait` pauses for human review, resumes with a token
- **External events** — `waitForEvent` blocks until an external system pushes data
- **Per-node retry** — exponential, linear, or constant backoff on any node
- **Scheduled triggers** — cron schedules as first-class records: many per flow, each with its own inputs, paused without touching the flow

### Portability
- **OpenClaw plugin** — run flows as agent tools today
Expand Down Expand Up @@ -478,8 +479,60 @@ Any string field supports `{{ path.to.value }}` interpolation resolved against f
**Important:** templates reference the **`output` key**, not the node name. If a node has `"name": "get_data", "output": "api"`, reference it as `{{ api }}` — not `{{ get_data }}`.

Flow state starts as `{ inputs: <payload> }` and grows as nodes complete. The
caller (CLI, webhook server, parent flow, dashboard) is responsible for
producing that payload — the flow itself is trigger-agnostic.
caller (CLI, webhook server, scheduled trigger, parent flow, dashboard) is
responsible for producing that payload — the flow definition itself stays
trigger-agnostic. Schedules live in their own records, not in the flow (see
[Scheduling](#scheduling)).

---

## Scheduling

A flow runs when something invokes it: an agent, an HTTP call, or a **trigger**.

A trigger is a first-class record — not a field on the flow definition:

```bash
flow_trigger action: "create" flow: "daily-digest" cron: "0 9 * * *" tz: "Europe/Rome"
```

```
● daily-digest-ab12cd34
flow: daily-digest (latest published)
cron: 0 9 * * * [Europe/Rome]
next: 2026-08-22T07:00:00.000Z
```

That separation is deliberate. A schedule is mutable operational state — paused
at 2am, retimed, pointed at a different version — while a published flow version
is an immutable artifact. Embedding one in the other would make "pause" mean
"publish a new version", and would let an unrelated publish silently re-arm a
schedule the draft happened to carry. Temporal made the same move from
cron-in-workflow to a separate Schedules object, for the same reason.

Because triggers are their own records, one flow can have many, each with its
own payload:

```bash
flow_trigger action: "create" flow: "digest" cron: "0 * * * *" inputs: '{"customer":"acme"}'
flow_trigger action: "create" flow: "digest" cron: "0 9 * * *" inputs: '{"customer":"globex"}'
```

| Behavior | Rule |
|---|---|
| Expression | Standard 5-field cron; minimum interval 60s |
| Timezone | IANA name (`Europe/Rome`); host local time when omitted |
| Version | Latest published by default; pin with `version: 2` |
| Missed runs | **Not replayed** — a host that was down at 09:00 waits for the next occurrence |
| Overlap | A tick is skipped if the previous run is still going |
| Flow deleted | Triggers are **paused**, not removed, so a restore keeps them |
| Approval | Arming/pausing/deleting a schedule is gated like flow authoring |

Records live in `.clawflow/triggers/<id>.json`, alongside `.clawflow/versions/`.
Out-of-process callers — the Clawnify hook server and dashboard — read and write
them by importing `TriggerStore` from `dist`, the same way they read published
versions. The running scheduler re-reads the directory every 30s, so a schedule
created or edited in the dashboard goes live without a restart.

---

Expand Down Expand Up @@ -542,6 +595,7 @@ Eleven tools registered in OpenClaw:
| `flow_read` | Read a flow definition (draft or specific version), inspect single nodes |
| `flow_publish` | Publish current draft as a new numbered version |
| `flow_edit` | Edit nodes in a flow definition (set, update, add, remove, move, wrap, revert, list) |
| `flow_trigger` | Schedule a flow on cron (create, update, list, pause, resume, delete, run_now) |

**Config:**
```json
Expand All @@ -557,7 +611,7 @@ Eleven tools registered in OpenClaw:
"agents": {
"list": [{
"id": "main",
"tools": { "alsoAllow": ["flow_create", "flow_delete", "flow_restore_from_bin", "flow_run", "flow_resume", "flow_send_event", "flow_status", "flow_list", "flow_read", "flow_publish", "flow_edit"] }
"tools": { "alsoAllow": ["flow_create", "flow_delete", "flow_restore_from_bin", "flow_run", "flow_resume", "flow_send_event", "flow_status", "flow_list", "flow_read", "flow_publish", "flow_edit", "flow_trigger"] }
}]
}
}
Expand Down
9 changes: 5 additions & 4 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"id": "clawflow",
"name": "ClawFlow",
"description": "The n8n for agents. Declarative, AI-native workflow engine LLM-writable, Cloudflare-portable.",
"version": "1.4.1",
"description": "The n8n for agents. Declarative, AI-native workflow engine \u2014 LLM-writable, Cloudflare-portable.",
"version": "1.6.0",
"skills": [
"./skills/clawflow"
],
Expand All @@ -21,7 +21,8 @@
"flow_list",
"flow_read",
"flow_publish",
"flow_edit"
"flow_edit",
"flow_trigger"
]
},
"configSchema": {
Expand Down Expand Up @@ -129,7 +130,7 @@
"gateMutations": {
"type": "boolean",
"default": true,
"description": "Gate flow authoring/publishing (flow_create/edit/publish/delete) behind approval on every call, independently of `enabled` (which governs flow_run). Set false to disable."
"description": "Gate flow authoring/publishing (flow_create/edit/publish/delete) and schedule changes (flow_trigger create/delete/pause/resume/run_now) behind approval on every call, independently of `enabled` (which governs flow_run). Set false to disable."
}
}
}
Expand Down
25 changes: 23 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clawnify/clawflow",
"version": "1.5.1",
"version": "1.6.0",
"description": "The n8n for agents. A declarative, AI-native workflow format that agents can read, write, and run.",
"type": "module",
"main": "./dist/index.js",
Expand Down Expand Up @@ -85,5 +85,8 @@
"homepage": "https://github.com/clawnify/clawflow#readme",
"bugs": {
"url": "https://github.com/clawnify/clawflow/issues"
},
"dependencies": {
"croner": "^10.0.1"
}
}
46 changes: 46 additions & 0 deletions skills/clawflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,13 +570,59 @@ flows/
triggers, dashboard runs) resolve the same way: latest published version, draft
only when nothing is published. So a draft edit does not change what a live
trigger executes once the flow has been published at least once.
- Scheduled triggers (`flow_trigger`) resolve the same way unless pinned to a
version — see "Scheduling a flow" below.
- `flow_run file: "my-flow" draft: true` — explicitly run the working copy
- `flow_run file: "my-flow" version: 2` — run a specific version
- `flow_read file: "my-flow" version: 1` — inspect a specific published version
- Version numbers are auto-incrementing integers (1, 2, 3...) — no semver
- Edits to the draft never affect published versions
- **Do NOT create separate files for versions** (e.g. `my-flow-v2.json`). Use `flow_publish` instead.

## Scheduling a flow

`flow_trigger` runs a flow on a cron schedule on this box. A trigger is a
**separate record**, not a field on the flow: one flow can carry several
triggers with different cadences and different inputs, and pausing one never
edits the flow or mints a new version.

```
flow_trigger action: "create" flow: "daily-digest" cron: "0 9 * * *" tz: "Europe/Rome"
flow_trigger action: "update" id: "daily-digest-ab12cd34" cron: "0 7 * * *"
flow_trigger action: "list"
flow_trigger action: "pause" id: "daily-digest-ab12cd34"
flow_trigger action: "resume" id: "daily-digest-ab12cd34"
flow_trigger action: "run_now" id: "daily-digest-ab12cd34"
flow_trigger action: "delete" id: "daily-digest-ab12cd34"
```

**Per-trigger inputs** — the same flow, two customers, two cadences:

```
flow_trigger action: "create" flow: "digest" cron: "0 * * * *" inputs: { "customer": "acme" }
flow_trigger action: "create" flow: "digest" cron: "0 9 * * *" inputs: { "customer": "globex" }
```

**Rules:**
- Standard 5-field cron. The minimum interval is 60 seconds — sub-minute
schedules are rejected, not silently accepted.
- `tz` is an IANA name (`Europe/Rome`). Host local time when omitted.
- A trigger runs the **latest published version** by default. Pass `version: 2`
to pin one — useful when you want a schedule to keep running a known-good
definition while the draft moves on.
- **Missed runs are not replayed.** If the host was down at 09:00 the trigger
does not fire at boot; it waits for the next occurrence.
- Runs never overlap: if the previous run is still going, the tick is skipped.
- Soft-deleting a flow (`flow_delete`) **pauses** its triggers rather than
removing them, so a restore keeps them — restore then requires an explicit
`resume`, so nothing silently re-arms.
- Triggers are plain records on disk, so the Clawnify dashboard reads and edits
the same ones you see here. A change made there is picked up by the running
scheduler within ~30s — no restart.
- Arming, editing, pausing, resuming or deleting a schedule requires approval by default
(same gate as flow authoring) — a schedule runs a flow unattended with tool
access, so it is treated as a mutation.

## Reading and discovering flows

- `flow_list` — lists all flows with their description, declared `inputs:` block, and published version info
Expand Down
43 changes: 43 additions & 0 deletions src/core/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,46 @@ export function publishDraft(workspace: string, file: string): PublishResult {
totalVersions: nextVersion,
};
}

/**
* Resolve what an incoming trigger should execute: a pinned version when one is
* requested, else the latest PUBLISHED version, else the draft.
*
* This is the one place that answers "which definition does a trigger run".
* The flow server, the scheduler, and any off-box caller share it so a webhook,
* a cron trigger, and an agent run can never execute different definitions of
* the same flow — the invariant 1.5.1 established.
*
* Returns null when the flow (or the pinned version) does not exist.
*/
export function resolveRunnableFlow(
workspace: string,
flowsDir: string,
flowName: string,
version?: number | "@published",
): { def: FlowDefinition; version: number | null; source: string } | null {
const safe = flowName.replace(/[^a-zA-Z0-9_-]/g, "");
if (!safe) return null;

if (typeof version === "number") {
const def = readVersion(workspace, safe, version);
return def ? { def, version, source: `v${version}` } : null;
}

const latest = readLatestVersion(workspace, safe);
if (latest) {
return { def: latest.def, version: latest.version, source: `v${latest.version}` };
}

const file = path.join(flowsDir, `${safe}.json`);
if (!fs.existsSync(file)) return null;
try {
return {
def: JSON.parse(fs.readFileSync(file, "utf8")) as FlowDefinition,
version: null,
source: "draft (no published versions)",
};
} catch {
return null;
}
}
Loading
Loading