Skip to content

Building a workflow (JSON schema)

A TechWatchAlert workflow is an automation graph: a trigger reacts to an event (new CVE, approaching end-of-life, expiring certificate…), conditions filter, and actions notify or act. The graph is described by a JSON object you manipulate through the visual editor, the Vigie assistant, or workflow import/export.

The graph (workflow.graph) has exactly two keys:

{
"nodes": [ /* the tiles */ ],
"edges": [ /* connections between tiles */ ]
}
{
"id": "c1",
"type": "condition.priorite",
"params": { "critere": "au_dessus_seuil" },
"position": { "x": 320, "y": 140 }
}
Field Type Description
id string Unique identifier within the graph (free-form: trigger, c1, a1…).
type string Tile type (see the catalog).
params object Tile parameters (depend on its type). {} if none.
position object (optional) { x, y } coordinates on the canvas. Omit it — the editor lays out tiles automatically.
{ "id": "e1", "source": "trigger", "target": "c1", "sourceHandle": "out" }
Field Type Description
source string id of the starting tile.
target string id of the destination tile.
sourceHandle string Output used on the source tile: out (triggers and actions) or true / false (conditions). Default: out.
id string (optional) Connection identifier.

A graph is rejected (with an explicit message) if it breaks any of these rules:

  • Exactly one trigger per workflow (trigger.*).
  • A trigger cannot have an input (no connection points to it).
  • Conditions have two outputs: true and false. A connection leaving a condition must use sourceHandle true or false.
  • Triggers and actions have a single output: out.
  • The graph is acyclic (DAG) — no loops.
  • 30 tiles maximum (MAX_NODES), depth 20 maximum from the trigger.
  • Tile ids are unique; every connection references existing tiles.
  • Tiles not connected to the trigger are ignored at runtime (tolerated on save so a draft isn’t blocked).
  • 10 workflows maximum per project.

The full registry is also served by the internal API (GET /projects/{id}/workflows/node-types) to build the editor palette.

A trigger reacts to an event. Its context (CVE, EOL or certificate) determines which downstream conditions make sense. None take parameters, except the compliance ones (below).

Type Event Context Fires when…
trigger.cve_new_match cve.new_match CVE a new CVE matches your monitored stack.
trigger.cve_updated cve.updated CVE a tracked CVE is updated (score, etc.).
trigger.cve_poc_published cve.poc_published CVE a public PoC appears for a tracked CVE.
trigger.cve_kev_added cve.kev_added CVE a tracked CVE enters CISA’s KEV catalog.
trigger.cve_exploitee cve.exploitee CVE a tracked CVE becomes known to be exploited (CISA KEV, VulnCheck, CERT-FR, EUVD).
trigger.cve_correctif_disponible cve.correctif_disponible CVE a fixed version is published for a tracked CVE that had none (once per CVE).
trigger.cve_rancongiciel cve.rancongiciel CVE a tracked CVE is linked to ransomware by CISA or VulnCheck (once per CVE).
trigger.eol_approaching eol.approaching EOL a tracked product’s end-of-life approaches.
trigger.eol_support_ending eol.support_ending EOL the end of active support approaches.
trigger.eol_new_release eol.new_release EOL a new release of a tracked product ships.
trigger.url_cert_expiring url.cert_expiring Certificate a monitored URL’s HTTPS certificate is about to expire.

Each condition has two outputs (true / false). A tile from a different family than the trigger (e.g. a CVE condition after an EOL trigger) has no data to read: it always returns false. The editor no longer offers them in its palette, and flags those that became stray after a trigger change.

Type Context Parameters
condition.priorite CVE critere ∈ exploitee (CVE exploited according to the policy: KEV first, EPSS, PoC) | kev_en_tete (in the KEV catalog, ranked first) | au_dessus_seuil (CVSS at the alert threshold) | sous_seuil_exception (below the threshold, alerted because exploited). Reads the project’s prioritization policy, refined by the threshold of the stack item that matched the CVE.
condition.has_poc CVE (none) — true if a public PoC exists.
condition.is_kev CVE (none) — true if the CVE is in the KEV catalog.
condition.product_name CVE op ∈ contains | starts_with | ends_with | equals; value text (≤ 100).
condition.vendor_name CVE same as product_name, on the vendor.
condition.match_type CVE value ∈ vendor | product (how the CVE matched your stack).
condition.case_status CVE op ∈ eq | ne; value ∈ a case status or NONE.
condition.has_tag CVE tag_id (UUID of an organization tag).
condition.eol_days_remaining EOL op (numeric); value days −3650 → 3650.
condition.eol_is_past EOL (none) — true if the end-of-life date has passed.
condition.eol_product_name EOL op (text); value text (≤ 100).
condition.cert_days_remaining Certificate op (numeric); value days −3650 → 3650.
condition.cert_is_expired Certificate (none) — true if the certificate has expired.
condition.url_contains Certificate op (text); value text (≤ 100).

Deprecated threshold tiles. condition.severity (op ∈ gte | eq | in; value or values, severities CRITICAL, HIGH, MEDIUM, LOW, NONE, UNKNOWN), condition.cvss and condition.epss (numeric op; value 0 → 10 or 0 → 1) duplicated the project’s prioritization policy (project settings, Prioritization tab): the “from which score” is now set there. They are no longer in the palette, but a graph that contains them still validates and runs exactly as before; the editor marks them “Deprecated” and offers to replace them with condition.priorite. Case statuses: PENDING, ANALYZING, FIXING, MITIGATED, ACCEPTED, RESOLVED, NOT_APPLICABLE. condition.case_status accepts them all. action.create_case does not accept MITIGATED, and skips ACCEPTED at run time (the run log says why): these statuses require a measure, a justification and an expiry date, set by a person.

Actions run when the flow reaches them. They have an out output and can be chained.

Type Parameters Effect
action.notify_inapp (none) In-app notification.
action.send_email recipients (optional): restricts to the listed member IDs; empty = all relevant members. ai_enrich (boolean, optional) and ai_instruction (≤ 500): see below. Email.
action.fire_webhooks webhook_id (optional): a specific webhook, empty = all active webhooks; format ∈ json | slack | discord | teams; ai_enrich / ai_instruction as above. Signed outbound webhook (see Webhook signature), sent after the event is saved, once per project event.
action.create_case status ∈ a case status, except MITIGATED (required). Creates an alert case, only if the flow reaches the tile. Required for an alert to appear under “Alerts” and for the email to include the “View alert” button.
action.add_tag tag_id (UUID, required). Adds a tag to the CVE in the project (auto-classification).

ai_enrich: true on an action.send_email or action.fire_webhooks tile attaches a short analysis to the message: risk level, why, and first remediation steps. ai_instruction (≤ 500 characters) steers the wording — “focus on Debian remediation”, “answer in English”.

The analysis is produced from only the public technical data of the event. Neither user identities nor the project name are part of the request, and every email address is redacted, including from your own guidance.

format: "teams" produces an Adaptive Card in the attachments envelope a Teams webhook expects (Power Automate). Since Office 365 connectors were retired, the legacy MessageCard is no longer rendered — this is the format to use.

Every call is signed with the webhook’s secret, shown only once when it is created (and on each regeneration):

  • X-TWA-Timestamp: the send time, in Unix seconds;
  • X-TWA-Signature: sha256= + hex HMAC-SHA256 of X-TWA-Timestamp + "." + raw body.

cve.exploitee is sent once per CVE and per project. cvss_score and epss_score are null when the score is unpublished: that is not a zero.

{
"event": "cve.exploitee",
"project_id": "…",
"triggered_at": "2026-10-13T18:00:00+00:00",
"cve_id": "CVE-2026-0001",
"severity": "CRITICAL",
"cvss_score": null,
"epss_score": null,
"has_poc": false,
"is_kev": true,
"rancongiciel": false,
"description": "…",
"matched_type": "product",
"matched_value": "openssl"
}
expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
assert hmac.compare_digest(expected, signature) and abs(time.time() - int(ts)) < 300

The comparison rejects a timestamp five minutes off. A copy resent inside that delay still verifies: the sender does not keep signatures already sent.

The call goes out after the event is saved, once per project even when the workflow runs for every member; a transient error (timeout, 429, 5xx) is retried with increasing delays. The run log shows queued, then sent or error.

Valid (a workflow must have exactly one trigger), but does nothing.

{
"nodes": [
{ "id": "trigger", "type": "trigger.cve_new_match", "params": {} }
],
"edges": []
}

“When a new CVE in my stack is above the project’s alert threshold, notify Slack.” The threshold itself (CVSS 7, 9…) is set in the project’s prioritization policy.

{
"nodes": [
{ "id": "trigger", "type": "trigger.cve_new_match", "params": {} },
{ "id": "c1", "type": "condition.priorite", "params": { "critere": "au_dessus_seuil" } },
{ "id": "a1", "type": "action.fire_webhooks", "params": { "format": "slack" } }
],
"edges": [
{ "id": "e1", "source": "trigger", "target": "c1", "sourceHandle": "out" },
{ "id": "e2", "source": "c1", "target": "a1", "sourceHandle": "true" }
]
}

The c1 → a1 connection uses sourceHandle: "true": the action only fires when the condition is true.

“KEV → alert case + email; otherwise → simple in-app notification.”

{
"nodes": [
{ "id": "trigger", "type": "trigger.cve_new_match", "params": {} },
{ "id": "kev", "type": "condition.is_kev", "params": {} },
{ "id": "case", "type": "action.create_case", "params": { "status": "PENDING" } },
{ "id": "mail", "type": "action.send_email", "params": {} },
{ "id": "inapp", "type": "action.notify_inapp", "params": {} }
],
"edges": [
{ "id": "e1", "source": "trigger", "target": "kev", "sourceHandle": "out" },
{ "id": "e2", "source": "kev", "target": "case", "sourceHandle": "true" },
{ "id": "e3", "source": "case", "target": "mail", "sourceHandle": "out" },
{ "id": "e4", "source": "kev", "target": "inapp", "sourceHandle": "false" }
]
}

Actions chain (case → mail via out). The false branch leads to a lightweight notification.

{
"nodes": [
{ "id": "trigger", "type": "trigger.cve_kev_added", "params": {} },
{
"id": "mail",
"type": "action.send_email",
"params": { "ai_enrich": true, "ai_instruction": "Prioritize remediation." }
}
],
"edges": [
{ "id": "e1", "source": "trigger", "target": "mail", "sourceHandle": "out" }
]
}

The analysis is a property of the message, not a step in the graph: nothing left to order.

{
"nodes": [
{ "id": "trigger", "type": "trigger.eol_approaching", "params": {} },
{ "id": "c1", "type": "condition.eol_days_remaining", "params": { "op": "lte", "value": 90 } },
{ "id": "a1", "type": "action.send_email", "params": {} }
],
"edges": [
{ "id": "e1", "source": "trigger", "target": "c1", "sourceHandle": "out" },
{ "id": "e2", "source": "c1", "target": "a1", "sourceHandle": "true" }
]
}

Once NIS2 is enabled on a project, its compliance notices (remediation due dates, end of life, inventory reviews, accepted risks, and at organization level: incidents, suppliers, governance) are sent by workflows created on activation. They are edited like any other; their own tiles:

Type Scope Parameters
trigger.conformite_projet project workflow evenements (required, list) among conformite.echeance_proche, conformite.echeance_depassee, conformite.echeance_escaladee, conformite.fin_de_vie_depassee, conformite.fin_de_vie_escaladee, conformite.revue_due, conformite.risque_a_approuver, conformite.risque_decide, conformite.risque_a_revoir.
trigger.conformite_org organization workflow evenements (required, list) among conformite.incident_echeance, conformite.fournisseur_a_evaluer, conformite.rapport_pret, conformite.fonction_attribuee.
condition.evenement_conformite both evenements (required, list) — true if the notice is one of them.

After a compliance trigger, action.notify_inapp and action.send_email take destinataires (list): prevus (the person the notice concerns — owner, requester, approver… — or else the administrators), rssi (security lead), direction (management), incident (incident contact), admins_projet, admins_org. Empty = prevus. recipients adds organization members to the email. action.fire_webhooks only exists in a project workflow. Nobody receives the same notice twice on the same channel, even when two workflows target them.

{
"nodes": [
{ "id": "trigger", "type": "trigger.conformite_projet", "params": { "evenements": ["conformite.echeance_escaladee"] } },
{ "id": "mail", "type": "action.send_email", "params": { "destinataires": ["rssi", "direction"] } }
],
"edges": [{ "id": "e1", "source": "trigger", "target": "mail", "sourceHandle": "out" }]
}
Message Cause
Le workflow doit contenir exactement un déclencheur Zero or several trigger.* tiles.
Sortie '…' invalide pour … Inconsistent sourceHandle (e.g. out on a condition, or true on an action).
Le workflow contient une boucle The graph is not acyclic.
Un déclencheur ne peut pas avoir d'entrée A connection points to the trigger.*.
Paramètre requis '…' manquant pour … A required parameter is missing (e.g. critere on condition.priorite).
Valeur '…' invalide pour … Value outside the allowed list (severity, status, operator…).
Valeur hors bornes pour … Number out of range (CVSS 0–10, EPSS 0–1, days −3650–3650).
La tuile … n'est pas disponible dans un workflow d'organisation Project-only tile (or the reverse), e.g. action.fire_webhooks in an organization workflow.
Trop de tuiles (max 30) / Workflow trop profond (max 20) Graph too large.
  • Easiest: describe your need to Vigie (the AI assistant) on the Workflows page — it generates the graph, which you approve.
  • The visual editor produces and reads this same JSON; exporting a workflow yields a re-importable { name, description, graph } file.
  • See also: Workflows (API).