Destaris
Browse the docs

AI authoring skill

The Claude Code skill Destaris installs so AI can author your workflows.

When you add a workspace folder, Destaris scaffolds a Claude Code skill into .claude/skills/destaris-workflows/SKILL.md. With it, Claude Code or Codex working in that folder understands the Destaris node catalogue, per-node config, the JSONata expression scope, and the authoring rules — so you can ask the AI to build or fix a workflow and it already knows the format.

What it teaches

  • Every node type the engine runs, and each one's key config.
  • How nodes and edges compose a workflow graph.
  • The JSONata expression scope (earlier steps by id, trigger, and item/index/prev) plus the ${NAME} and {{path}} reference syntaxes.
  • Inline foreach/loop bodies, sub-workflows, and the draft/publish model.

Using it

It installs automatically — you don't have to do anything. Open the workspace folder in Claude Code or Codex and edit (or ask it to create) a file under .destaris/workflows/; the skill activates for those files.

The skill

This is the exact SKILL.md the app ships (kept in lockstep with the engine). Read it below, or download it to drop into any .claude/skills/destaris-workflows/ folder yourself.

.claude/skills/destaris-workflows/SKILL.md

Author Destaris workflow YAML — node catalogue, per-node config, inline foreach/loop, JSONata scope, ${NAME}/{{path}} references. Use when creating or editing files under .destaris/workflows/.

---
name: destaris-workflows
description: Author Destaris workflow YAML — node catalogue, per-node config, inline foreach/loop, JSONata scope, ${NAME}/{{path}} references. Use when creating or editing files under .destaris/workflows/.
---

# Authoring Destaris workflows

A workflow is a YAML file at `.destaris/workflows/<name>.yaml`. The engine runs it as a directed
graph: `nodes` do the work, `edges` wire one node's completion to the next.

```yaml
name: my-workflow            # unique id the engine runs by (also the sub-workflow handle)
title: Human-friendly title  # optional
group: Sales CRM/Find        # optional — "/"-nested sidebar folder; a leading "N " orders siblings
nodes:
  - id: tick                 # ids must match /^[A-Za-z_][A-Za-z0-9_]*$/ — letters/digits/_, no hyphens
    type: trigger.cron
    config: { cron: "0 9 * * *" }
  - id: fetch
    type: task.http
    config: { url: "https://api.example.com/items" }
  - id: draft
    type: agent.run
    config: { prompt: "Summarise these items: {{fetch.body}}" }
edges:
  - { from: tick, to: fetch }
  - { from: fetch, to: draft }
```

## Node catalogue (the only types the engine runs)

| type | purpose | key config |
| --- | --- | --- |
| `trigger.cron` | run on a schedule | `cron` (5-field crontab) |
| `trigger.command` | run manually / on demand | — (the manual Run button; payload becomes `trigger`) |
| `trigger.webhook` | run on an incoming webhook | — |
| `task.http` | HTTP/REST call | `url` (or `urlExpression`), `method`, `headers`, `query`, `bodyExpression`, `auth`, `parse` |
| `task.command` | run a local shell command / script | `command`, `cwd`, `shell`, `timeoutSeconds` |
| `task.transform` | reshape data (JSONata) | `expression` |
| `task.branch` | conditional routing (JSONata → branch labels) | `expression` |
| `task.run-workflow` | run ANOTHER workflow once, by name | `workflow`, `payloadExpression` |
| `task.foreach` | inline loop over an array | `items` (JSONata → array) |
| `task.loop` | inline while/until-loop | `while` **or** `until` (JSONata guard), `maxIterations` (default 1, max 25), `detectNoProgress` (default true) |
| `task.wait` | pause the run (pace a loop) | `duration` (`30s`/`5m`/`1h`, max 6h) or `durationExpression` (JSONata → a duration) |
| `task.cache-unseen` | filter a list to items not yet seen | `items`, `key`, `namespace`, `claim`, `claimTtl` |
| `task.cache-mark` | mark keys as seen (persistent dedupe) | `keys`, `namespace` |
| `agent.run` | run an AI agent | `prompt` or `agent`; `model`, `effort`, `profile`, `access`, `allowTools`, `runner`, `contextKeys`, `decision`, `timeoutSeconds` |
| `agent.switch` | AI picks WHICH catalogue workflow to run, then dispatches it | `scope` (`{ folder }` or `{ workflows: [...] }`), `includeGenerated` (default false), `prompt`, `payloadExpression`, `model`, `effort` |
| `agent.invent-workflow` | AI DRAFTS a new workflow, validates it, saves it under `generated/`, then runs it | `prompt` (what to build), `scope` (optional reuse folder), `includeGenerated`, `payloadExpression`, `model`, `effort` |

An unknown `type` fails validation and the whole workflow is skipped — use only the types above.

## What each node outputs (for {{refs}})

`{{node.field}}` resolves to a field of an EARLIER node's output. Only nodes that run before this one
are available. The common shapes:

- `task.http` → `status`, `statusText`, `body` (parsed JSON or text), `headers`. With `parse`, `body` is the extracted array.
- `task.command` → `stdout`, `stderr`, `exitCode`.
- `task.transform` → `result` (the evaluated expression).
- `agent.run` → `text` (the whole reply, as ONE unparsed string) and — ONLY when `config.decision` is set — that schema's fields, **bare** on the node output (`{{research.companies}}`). There is no `.data` hop: `{{node.data.<field>}}` is never right. Without a `decision` schema every field but `text` **resolves to null**, silently. See "Agents (agent.run)" below.
- `agent.switch` → `workflow` (the chosen name, or `"unmatched"`), `why`, `origin`, `matched`, and `outputs` (the chosen workflow's named `outputs` if it declares them, else its raw node map).
- `agent.invent-workflow` → `created` (the new workflow's name, or `null` on failure), `path` (its `generated/` file), `provenance`, `reasoning`, and `outputs` (the created workflow's run outputs).
- `task.foreach` / `task.loop` → an array of `{ item, index, outputs }`, one entry per iteration.
- `task.wait` → `waitedMs` (how long it paused) and `duration` (the label, e.g. `5m`).
- A `trigger.*` node → the trigger payload (use `trigger` in JSONata).

## References & templating

- `{{node.field}}` / `{{trigger.field}}` — interpolated into `agent.run` prompts and string config.
- `${NAME}` — a secret or variable from **Settings** (resolved at run time in `task.http` config and agent prompts). Never paste raw secrets into a workflow file. **Keep SECRETS out of agent prompts.** A rendered prompt is stored with the run and shown in the run view, so a secret interpolated into one is persisted locally and handed to the runner's model. Put credentials in `task.http` `headers`/`auth`, where they are resolved at call time and never rendered into a transcript; reserve `${NAME}` in a prompt for non-secret variables.
- **JSONata** (in `task.transform` `expression`, `task.branch` `expression`, `task.foreach` `items`, `task.http` `urlExpression`/`bodyExpression`): reference data **bare** — `fetch.body`, `trigger`, and inside a loop body `item`/`index`/`prev`. (Bare in JSONata; `{{...}}` only in prompts/strings.)

## Loops are inline (foreach / loop)

`task.foreach` and `task.loop` do NOT call a sub-workflow. They loop over the steps wired from their
**`loop`** output edge; an optional **`done`** edge runs once after the loop finishes.

- Body edges are labelled `when: loop`; the post-loop edge is `when: done`.
- Inside the body, JSONata/prompts see `item` (foreach only), `index` (0-based), and `prev` (the previous iteration's outputs, `null` on the first round).

```yaml
nodes:
  - id: fetch
    type: task.http
    config: { url: "https://api.example.com/candidates" }
  - id: each
    type: task.foreach
    config: { items: "fetch.body" }      # JSONata → the array to iterate
  - id: handle
    type: agent.run
    config: { prompt: "Process candidate {{index}}: {{item}}" }
  - id: summarise
    type: agent.run
    config: { prompt: "Done. Processed {{each}}" }
edges:
  - { from: fetch, to: each }
  - { from: each, to: handle, when: loop }   # body — runs once per item
  - { from: each, to: summarise, when: done } # runs once after the loop
```

`task.loop` is the same shape but instead of `items` uses a post-round JSONata guard — either `while`
(repeat **while** it stays true) or `until` (repeat **until** it becomes true; the positive-condition
complement, ideal for "refine until the critic approves"). Set at most one of `while`/`until`, plus
`maxIterations` (hard ceiling 25). It also halts early when two consecutive rounds produce identical
body output (`detectNoProgress`, default on; set `false` only if identical rounds are expected).

Pace a poll loop with `task.wait` in the body — `check` (task.http) → `wait` (5m) — so the loop
re-checks the world every few minutes instead of hammering it. A body containing `task.wait`
auto-relaxes no-progress detection (identical "still running" rounds are expected). Minutes-scale
only: for hours/days-scale or must-survive-restart waits, use a `trigger.cron` heartbeat + a cache
dedupe instead — a `task.wait` is an in-process timer that a restart interrupts.

## Branching (task.branch)

`task.branch` evaluates `expression` (JSONata) and activates outgoing edges whose `when:` matches the
result: a boolean → `"true"`/`"false"`; a string → that label; an array → each label. Wire one edge
per branch with the matching `when:`.

## Sub-workflows (task.run-workflow)

`task.run-workflow` is the ONLY node that calls another workflow. It runs the workflow named in
`config.workflow` once, passing `config.payloadExpression` (JSONata) as that run's trigger payload
(read it there via `trigger`). Create the callee as its own `.destaris/workflows/<name>.yaml`. If the
callee declares `inputs:`, your payload is validated against them; if it declares `outputs:`, this
node's output is those named outputs (see below).

## Inputs & outputs (contracts)

A workflow can declare a CONTRACT so callers compose against a stable interface instead of reaching into
its internal node ids.

- **`inputs:`** (top-level; a map of name → `{ type, required, description }`) — what the workflow
  NEEDS. The caller's payload is validated against it at call time (a missing required field, or the
  wrong type, fails with a plain-language error), and each value is read inside the workflow via
  `trigger.<name>`. Types: `string`, `number`, `boolean`, `object`, `array` — every field is
  optional, so a bare `issueKey: {}` just declares the name (any type, optional).
- **`outputs:`** (top-level; a map of name → a JSONata expression over your nodes) — what the workflow
  HANDS BACK. When declared, this named object is the ONLY thing a caller sees; the internal node ids
  become private. Omit `outputs:` and a caller gets the raw node-output map (every node id → its
  output) — the back-compat default, so existing chains keep working.

```yaml
name: summarise-issue
inputs:
  issueKey: { type: string, required: true, description: "The Jira key to summarise" }
outputs:
  summary: draft.text          # JSONata over THIS workflow's nodes; the only thing a caller sees
nodes:
  - id: t
    type: trigger.command
    config: {}
  - id: fetch
    type: task.http
    config: { urlExpression: "'https://api.example.com/issues/' & trigger.issueKey" }  # reads the input
  - id: draft
    type: agent.run
    config: { prompt: "Summarise this issue: {{fetch.body}}" }
edges:
  - { from: t, to: fetch }
  - { from: fetch, to: draft }
```

### Chaining sub-workflows (a parent calls A then B)

Because a callee exposes only its named `outputs`, a parent can run A then B and pass A's output into
B's input cleanly — without knowing either one's internal steps:

```yaml
name: chain
nodes:
  - id: t
    type: trigger.command
    config: {}
  - id: callA
    type: task.run-workflow
    config: { workflow: summarise-issue, payloadExpression: "{ 'issueKey': trigger.key }" }
  - id: callB
    type: task.run-workflow
    # B's input is fed from A's NAMED output (callA.summary) — never A's internal node ids.
    config: { workflow: post-summary, payloadExpression: "{ 'text': callA.summary }" }
edges:
  - { from: t, to: callA }
  - { from: callA, to: callB }
```

## Agentic switcher (agent.switch)

`agent.switch` is the dynamic form of `task.run-workflow`: an AI step that picks WHICH workflow to run
from a scoped catalogue, then dispatches it. Scope the catalogue with `config.scope` — either
`{ folder: <name> }` (every workflow under that folder) or `{ workflows: [<name>, ...] }` (an explicit
list) — plus `includeGenerated` (default false: owned-only; set true to also offer agent-generated
workflows). The agent's choice is constrained to the catalogue (it cannot invent a workflow name); when
nothing fits it returns `"unmatched"`. Wire the outgoing edges by `when:` — `when: "matched"` for the
dispatch/continuation path, `when: "unmatched"` to route a decline to a human. The chosen workflow's
outputs land under `{{route.outputs.<name>}}` (its named `outputs`, or `<nodeId>` when it declares
none); the decision is `{{route.workflow}}` / `{{route.why}}` / `{{route.origin}}`.

## Workflow inventor (agent.invent-workflow)

`agent.invent-workflow` is the self-extending step: an AI step that DRAFTS a whole new workflow for a
task, validates it through the real loader, saves it under the `generated/` namespace with a provenance
stamp (which run made it, when, from what input), then RUNS it — creation and execution linked in one
run record. The generated workflow IS the trace: you see what the agent made AND what it ran. Invalid
drafts never land (one retry on a validation error, then the node fails cleanly). Generated workflows
are badged and stay separate from your owned ones until you PROMOTE one (a human-only action).

```yaml
- id: invent
  type: agent.invent-workflow
  config:
    prompt: "When a new incident arrives, page on-call and open a linked ticket."
    scope: { folder: handlers }   # optional — the workflows the agent should prefer to reuse
    model: claude-opus-5
    effort: high
```

Read the result via `{{invent.created}}` (the new workflow's name), `{{invent.path}}`, and
`{{invent.outputs.<name>}}` (what running it produced) — where `<name>` is one of the created
workflow's declared `outputs` keys if it has an `outputs:` contract, and a NODE ID only when it does
not. Reading a node id off a workflow that declares `outputs` yields null: callers see the named
output object, never the raw node map. A good pattern: an `agent.switch` whose
`when: "unmatched"` edge feeds an `agent.invent-workflow`, so the first time no existing workflow fits,
the agent invents (and runs) one — and next time the switcher can dispatch to it.

## Reuse before invent

When you (or the AI editor) author a workflow, and especially when `agent.invent-workflow` drafts one:
**check what already exists and reuse it.** If an existing workflow already does part of the task, CALL
it with a `task.run-workflow` node instead of reimplementing its steps. A workflow that composes three
existing workflows is more trustworthy than one that reinvents them — it inherits their tested behaviour.

## When to factor a reusable workflow

The flip side of reuse-before-invent: build the reusable pieces the next workflow will reuse. When part
of the task is a **self-contained capability that will recur** — "open a PR", "post a Slack message",
"fetch and normalise a record" — author it as **its own workflow** with declared `inputs:`/`outputs:` and
CALL it from the parent via `task.run-workflow`, instead of inlining its steps into every workflow that
needs it.

Factor a piece out when all three hold:

- **Self-contained** — a clear job and a clean input → output boundary (a few `inputs:`, a small named
  `outputs:`), not tangled into the parent's other steps.
- **Recurring** — the kind of thing more than one workflow will want. A step used in exactly one place is
  not worth its own file.
- **Stable** — its interface won't churn every run. Extract the settled parts; leave the still-changing
  ones inline until they settle.

Don't over-factor: a single trivial node (one `task.http`, one `task.transform`) is not a workflow —
wrapping it just adds a hop. The aim is a small, growing library of solved, composable pieces, so each
workflow makes the next one cheaper — the same instinct as keeping functions small and single-purpose.

## Dedupe & overlap (don't process the same item twice)

Polling on a schedule means runs can overlap or re-fire. Two guards keep the same item from being
processed twice:

- **Per-workflow `overlap`** (top-level, alongside `name`): what the scheduler does when a cron fires
  while a previous run of the SAME workflow is still going. `skip` — the default for a cron-triggered
  workflow — drops the fire and records a "skipped — previous run still going" entry in run history;
  `queue` starts one run once the current one finishes (bounded to a single pending fire — extra fires
  coalesce); `allow` lets runs overlap (the old behaviour). A manual **Run** always runs, never skipped.
- **`task.cache-unseen` + `task.cache-mark`** filter a list to items not handled before (per
  `namespace`, default the workflow name) and record them once done. Set `claim: true` on
  `task.cache-unseen` to ALSO reserve each item it passes through — a `claimTtl`-lived in-flight hold
  (default 15m) — so a concurrent run (a sibling workflow sharing the `namespace`, or the next fire)
  skips it instead of double-actioning it. The hold releases on its own if a run is interrupted;
  `task.cache-mark` then finalises the item permanently.

```yaml
name: poll-intake
overlap: skip                # scheduled default — don't re-fire onto an in-flight run
nodes:
  - id: unseen
    type: task.cache-unseen
    config: { items: "fetch.items", key: "id", namespace: intake, claim: true, claimTtl: "15m" }
  - id: each
    type: task.foreach
    config: { items: "unseen" }        # cache-unseen's output IS the filtered array
  - id: handle
    type: agent.run
    config: { prompt: "Handle intake item {{item}}" }
  - id: mark
    type: task.cache-mark              # INSIDE the body: `item` exists only here, and marking
    config: { keys: "item.id", namespace: intake }   # only after the work succeeded is the point
edges:
  - { from: unseen, to: each }
  - { from: each, to: handle, when: loop }
  - { from: handle, to: mark, when: loop }
```

Mark INSIDE the loop, after the work — never as a top-level node. `item` is scoped to a loop body, so
a `task.cache-mark` outside one has no `item` to read and marks nothing; and marking before the work
succeeds means a failed item is never retried.

## task.http details

- `url` is literal; `urlExpression` is JSONata (evaluated first, then `${VAR}`-resolved). `headers`/`query` values are `${VAR}`-resolved.
- `bodyExpression` (JSONata) is JSON-encoded and sent with `content-type: application/json`.
- `auth: { type: basic, username, password }` sets the Authorization header.
- `parse` extracts + paginates a list. Builtins include `github:list`, `sentry:issues`, `jira:search`, `jira:comments`, `gitlab:list`, `linear:issues`; or `parse: { expression: "<jsonata>" }` for a custom shape.

## Agents (agent.run)

- `prompt` is the instruction (with `{{refs}}` and `${VARS}`). Or set `agent` to a saved agent file (`.destaris/agents/<name>.md`); `prompt` then appends extra guidance to it.
- `model` (e.g. `claude-opus-5`, `claude-fable-5`; Codex: `gpt-5.6-sol`), `effort` (`low|medium|high|xhigh|max`, Claude only), `runner` (`claude`, `codex`, `rovo`, or `gemini`).
- `profile`: `constrained` (plain model call, no tools — the default), `surface` (MCP tools), `full` (Claude Code skills + tools). Set `surface`/`full` if the agent must use tools/MCP.
- `access` (permission posture — separate from `profile`, which is about WHICH tools): `standard` (default — right for most steps. A run is unattended, so **nothing is ever queued for a human**: what would have been an approval prompt is decided by the engine from the step's `profile` — `constrained` has no tools at all, `surface` permits only its MCP tools, `full` permits the Claude Code toolset), `broad` (**the recommended fix when a headless step is "blocked"** — auto-approves writing in the working tree + git + package managers, so CI-style work runs without prompts, while staying sandboxed otherwise), or `full` (an unattended run with no permission prompts — see `scope` for how far it reaches). Reach for `broad` first — most "my agent is blocked" cases don't need `full`.
- `scope` (only meaningful with `access: full`): `workflow-folder` (**the default, and the safer one**) confines the step to the workflow's own folder on disk — it can change anything inside that folder, **no tool is withheld from it there**, and nothing outside — and needs **no** master switch, the cheap way to unblock a headless agent that only works in its own folder; `machine` is the UNSANDBOXED run — it can change anything on this computer — and STILL requires the workspace master switch (**Settings → Full access**) on, else the step fails with a clear error and nothing runs. Honest limit: folder scope is not a hard jail — file edits are guarded to the folder, but a shell can still `cd` out. (Runner mapping: claude → folder: `acceptEdits` + a path-guard that denies out-of-folder file writes; machine: `bypassPermissions`. codex → folder: `-s workspace-write` with cwd pinned to the folder; machine: `-s danger-full-access`. gemini/rovo already run headless auto-approve, so scope only pins their working folder — the master switch still gates a `machine` node.)
- `allowTools` (extra pre-approvals, Claude runner): a list of tool patterns in the CLI's own syntax — `allowTools: ["Bash(gh:*)", "WebFetch"]` — appended to what `access: broad` already pre-approves. **Reach for this when a step is refused for one specific command**: `broad` covers git + package managers, so a step that also needs `gh`, `curl` or `jq` should name that family here instead of escalating its posture. Entries are shown in the run record, so an audit sees what the step was allowed to do. Ignored under `access: standard` (that posture's boundary is the `profile`, not a list). A **blanket** entry (`Bash`, `Bash(*)`, `*`) is rejected — auto-approving everything is what `access: full` + `scope: machine` is for, and that stays behind the master switch.

**When a headless step is refused**, the message names the blocked command and three fixes, in order of preference: add the family to `allowTools`; move the work into a deterministic node (`task.http` / `task.command` / `task.transform`) and let the agent only decide; or raise the step to `access: full` with `scope: workflow-folder`, which needs no master switch and withholds nothing inside the workflow's own folder.
- `decision`: a JSON schema naming the fields you want back. **This is the only way to read named fields off an agent step.** The agent must answer with an object matching it, and those fields land **bare** on the node's output — `research.companies`, not `research.data.companies` (`.data` does not exist). With no `decision`, the step's output is just `text` (one unparsed string) and every other field **resolves to null** with no error — the whole downstream chain then collapses to null too. Rule of thumb: if a later node reads anything other than `.text` off an agent step, that step is missing a `decision` schema.

```yaml
- id: research
  type: agent.run
  config:
    prompt: "Find companies matching {{trigger.brief}}"
    decision: { type: object, properties: { companies: { type: array } }, required: [companies] }
- id: records
  type: task.transform
  config: { expression: "research.companies" }   # bare, and readable ONLY because of `decision`
```

- `why` convention: give a `decision` schema a required `why` (or `reason`) string field so the agent records _why_ it chose what it chose as data, not prose in a log line. Every invocation's rendered prompt, transcript, and decision are stored locally and shown in the run view; a `why` field makes a step's reasoning auditable there. Not enforced by the engine — a documented convention the run view surfaces when present. Especially load-bearing for switcher/inventor steps ("picked X because Y").
- `contextKeys: [stepId]` — by default an agent receives ALL upstream outputs as JSON context; list only the keys you need. This is not just a size control: whatever lands in that context is sent to the runner's model AND persisted in the run's transcript, so an upstream `task.http` that fetched customer records, tokens, or PII hands all of it over unless you narrow it. **Set `contextKeys` on every agent step downstream of a node that fetches personal or credentialed data**, and on any step whose upstream payload is large enough to crowd the model's limit.

## Draft vs published

The editor autosaves a draft; the engine keeps running the last **published** version until you hit
Publish. A manual Run uses the draft. So edit freely — scheduled runs won't pick up changes until you
publish.