Node Types
Gondo workflows use four node types: trigger, task, code, and human.
Shared node fields
Most nodes share this shape:
id: example_node
type: task
title: Example node
goal: One short sentence for people reading the graph.
integrations:
- finance_gmail
config: {}
ui:
x: 0
y: 0
integrations lists the integration reference IDs the node is allowed to use.
Trigger nodes
Every published workflow has exactly one trigger: Manual, Auto, or Webhook. The trigger stays in the graph and its data schema describes the workflow inputs.
Manual trigger
Use a Manual trigger when the job runs only when explicitly requested.
type: trigger
title: Run
config:
kind: manual
name: review-invoices
dataSchema:
type: object
properties:
folderId:
type: string
required: [folderId]
The config.name field above names the manual trigger, not the workflow itself.
The trigger output is available downstream as ctx.data.<triggerNodeId>.
Auto trigger
Use an auto trigger when the workflow should run on a schedule.
type: trigger
title: Weekday check
config:
kind: auto
cron: '0 9 * * 1-5'
timezone: Europe/London
An auto trigger can also run code before starting workflow runs.
type: trigger
title: New Gmail messages
integrations:
- finance_gmail
config:
kind: auto
cron: '*/15 * * * *'
code: |
const gmail = getIntegration('finance_gmail')
const messages = await (await gmail.get('/messages?q=invoice')).json()
for (const message of messages.messages ?? []) {
if (await store.get(`seen.gmail.${message.id}`)) continue
await triggerWorkflow({ messageId: message.id })
await store.set(`seen.gmail.${message.id}`, true)
}
dataSchema:
type: object
required: [messageId]
properties:
messageId:
type: string
Auto trigger code can use store.get, store.set, store.delete, and triggerWorkflow(data). The runtime does not suppress identical inputs. Use stored markers, as above, or a provider’s incremental cursor to choose new work. Save the cursor returned by the provider after requesting the runs for that page; do not substitute the current time, which could skip items. Follow the provider’s pagination contract when processing longer lists.
Webhook trigger
A webhook starts when an external service sends an authenticated JSON request. Configure the endpoint and secret header in Studio; credentials are not part of YAML. Required code reads ctx.event.body, filters events, and calls triggerWorkflow(data) zero, one, or several times. Its dataSchema describes the resulting workflow inputs, not the provider event envelope.
type: trigger
title: Successful payment
config:
kind: webhook
code: |
const event = ctx.event.body
if (event.type !== 'payment.succeeded') return
if (typeof event.id !== 'string' || !event.id) throw new Error('Missing payment event ID')
const key = `payment.${event.id}`
if (await store.get(key)) return
await triggerWorkflow({ invoiceId: event.data.invoiceId })
await store.set(key, true)
dataSchema:
type: object
required: [invoiceId]
properties:
invoiceId:
type: string
additionalProperties: false
Duplicate handling is ordinary code: the example uses the sender’s event ID as a store key. There is no event-ID selector or automatic payload deduplication. To forward the body unchanged, write await triggerWorkflow(ctx.event.body). Zero calls ignore the event. Webhook code uses the same workspace helpers and limits as Auto. After successful code execution, the runtime creates all requested runs, then saves store changes. A failure between those steps can cause repeats on retry. Sample events in Studio immediately start real runs and update the same workflow store; use a fresh sample ID when you want another run.
Employees can explicitly run their own published jobs through any trigger kind. They supply the trigger's expected inputs directly; Auto discovery code and Webhook mapping code are bypassed. Do not add a second Manual trigger. Preserve trigger IDs when changing kinds, since downstream references depend on them.
Task nodes
A task node is an AI reasoning step.
Use it when the workflow needs judgement, classification, drafting, document reading, messy data handling, or adaptable browser work.
type: task
title: Classify invoice
goal: Decide whether the invoice needs review.
integrations:
- finance_gmail
config:
instruction: |-
Read the invoice email identified by ctx.data.new_email.messageId.
Return whether it needs human review.
If the amount is missing, set needsReview to true and explain why.
dataSchema:
type: object
required: [needsReview, reason]
properties:
needsReview:
type: boolean
reason:
type: string
The task node returns structured data matching dataSchema. Request fields needed for downstream decisions, edits or saved business outputs. Do not add catch-all notes, issues or reviewIssues fields by default; represent missing values explicitly for the relevant field or branch.
Code nodes
A code node runs deterministic JavaScript.
Use it for API calls, structured writes, file movement in the workflow workspace, or transformations where the input shape is already clear.
type: code
title: Add row to sheet
goal: Write approved invoice data to the finance sheet.
integrations:
- finance_sheets
config:
code: |
const sheets = getIntegration('finance_sheets')
const invoice = ctx.data.classify_invoice
const response = await sheets.post('/values/A1:append', {
values: [[invoice.supplierName, invoice.amount]]
})
const result = await response.json()
return { updatedRange: result.updates?.updatedRange ?? null }
dataSchema:
type: object
required: [updatedRange]
properties:
updatedRange:
type: [string, 'null']
Code nodes can use:
ctx.datactx.runctx.varsgetIntegration('<referenceId>')log(...)
External provider calls must go through getIntegration().
Human nodes
A human node prepares a review, then either pauses for a person or completes automatically.
Use it when a person should review, approve, edit, or provide information.
Design the screen around the specific decision, correction or approval required. Include only information and controls that support that action. Do not add generic AI-generated notes, issues, risks or "requires attention" sections by default. Handle missing information beside the relevant field or through an explicit workflow branch. Add free-text input only when the process has a defined use for it, such as a rejection reason shown when rejecting. Keep build caveats and environment setup details in the authoring handoff.
Every newly authored human node requires a config.prepare JavaScript body. Return { review: { state, assigneeIds } } to pause, or { complete: output } to finish automatically. An empty assigneeIds list leaves the review with account admins/members. Select specific reviewers from backend ctx.users; any listed reviewer may respond.
Human node UI is stored as a Vue single-file component in config.component. When completed, its final state becomes ctx.data.<nodeId>.
This example assumes extract_invoice supplies the invoice details and a purchase order number, which this process requires before approval. The approval branch saves the confirmed purchase order; the rejection branch uses rejectionReason to request a corrected invoice. The reason input appears only after the reviewer chooses rejection.
type: human
title: Review invoice
goal: Confirm the purchase order or request a corrected invoice.
config:
prepare: |
return { review: { state: { purchaseOrderNumber: ctx.data.extract_invoice.purchaseOrderNumber ?? '', decision: 'pending', rejectionReason: '' }, assigneeIds: [] } }
dataSchema:
type: object
required: [purchaseOrderNumber, decision, rejectionReason]
properties:
purchaseOrderNumber:
type: string
decision:
type: string
enum: [pending, approved, rejected]
rejectionReason:
type: string
component: |
<template>
<section>
<h2>Review invoice {{ ctx.data.extract_invoice?.invoiceNumber }}</h2>
<p>{{ ctx.data.extract_invoice?.supplierName }} · {{ ctx.data.extract_invoice?.amount }} {{ ctx.data.extract_invoice?.currency }}</p>
<div v-if="readOnly">
<p>Decision: {{ state.decision }}</p>
<p v-if="state.decision === 'approved'">Purchase order: {{ state.purchaseOrderNumber }}</p>
<p v-else>{{ state.rejectionReason }}</p>
</div>
<div v-else-if="rejecting">
<label>
What must the supplier correct?
<textarea v-model="rejectionReason" />
</label>
<button type="button" :disabled="!rejectionReason.trim()" @click="reject">Request corrected invoice</button>
<button type="button" @click="rejecting = false">Back</button>
</div>
<div v-else>
<label>
Purchase order number
<input v-model="purchaseOrderNumber">
</label>
<button type="button" :disabled="!purchaseOrderNumber.trim()" @click="approve">Approve invoice</button>
<button type="button" @click="rejecting = true">Reject invoice</button>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
const { state, ctx, readOnly, submitAction } = defineProps({
state: { type: Object, required: true },
ctx: { type: Object, required: true },
readOnly: { type: Boolean, default: false },
submitAction: { type: Function, required: true },
})
const purchaseOrderNumber = ref(state.purchaseOrderNumber ?? '')
const rejectionReason = ref('')
const rejecting = ref(false)
function approve() {
submitAction('approve', { purchaseOrderNumber: purchaseOrderNumber.value })
}
function reject() {
submitAction('reject', { rejectionReason: rejectionReason.value })
}
</script>
<script backend>
export async function approve(state, inputs) {
const purchaseOrderNumber = String(inputs.purchaseOrderNumber ?? '').trim()
if (!purchaseOrderNumber) throw new Error('Enter the purchase order number')
return { ...state, purchaseOrderNumber, decision: 'approved', rejectionReason: '', done: true }
}
export async function reject(state, inputs) {
const rejectionReason = String(inputs.rejectionReason ?? '').trim()
if (!rejectionReason) throw new Error('Describe the correction needed')
return { ...state, decision: 'rejected', rejectionReason, done: true }
}
</script>
Human nodes should render both an active input state and a completed recap state.