Use workflow_create to create automated multi-step sequences that run on a schedule or on demand.

Building a deployed app with the CLI? Skip the agent tools: author the workflow as a workflows/<name>.yaml file and let gipity deploy reconcile it - jump to "Authoring in a YAML file (deployable)" below. That section (plus "Step types", "Sending email from an app", and "Template variables") is the whole contract for app-shipped workflows; workflow_create is the chat-time tool form of the same thing.

When to Suggest Workflows

When NOT to Use Workflows

How Workflows Work

Limits

Triggers

Scheduled Workflows

Authoring in a YAML file (deployable)

# workflows/post-ai-comment.yaml
name: ai-commenter
trigger: schedule
cron: "* * * * *"
steps:
  - name: post_comment
    step_type: function
    config: { name: post-ai-comment }

Step types

Each step has a step_type. Omit it (or step_type: llm) for an LLM step driven by prompt. Deterministic (no-LLM, no tokens) types take a config: object:

Error handling (on_failure)

Per step: on_failure: stop (default) fails the run at that step; continue records the error and proceeds (the step's output becomes { error }); retry re-runs the step up to 3 total attempts with backoff - good for flaky externals (LLM 529s, webhook targets) - then halts the run like stop if every attempt failed.

Fan-out (foreach)

Any deterministic step can run once per item of a prior step's list: set foreach: "{{drafts.items}}" on the step (top level in YAML). Inside, {{item}} / {{item.field}} resolve per iteration; the step's output becomes { count, items: [per-item outputs] }. Use arrays of objects (a primitive item lands under {{item.value}}). Max 25 items. Not valid on llm steps (one llm step can emit the whole list in one call - or foreach a workflow step whose child runs the llm work per item) or approval steps. With on_failure: continue, a failing item records { error } and the rest still run.

Composing workflows (workflow step)

step_type: workflow with config: { name: "post-reply", body: { text: "{{item.text}}" } } calls another of your workflows. The child reads the payload via {{trigger.payload.text}}. Default mode: await runs it to completion and surfaces its last step's output as {{step.output.<field>}}; mode: trigger fires and forgets. A workflow can't call itself and call depth is capped at 3. Don't await a child containing an approval gate. Combine with foreach to run a sub-pipeline per item.

Sending email from an app (notify step)

An app emails the owner (or anyone) via a notify step - the platform sends it, no Gmail/OAuth and no LLM tokens:

# workflows/contact-notify.yaml - email the owner on each new contact-form row
name: contact-notify
trigger: record.after_insert
table: contact_messages
steps:
  - name: email_owner
    step_type: notify
    config:
      channel: email
      to: owner@example.com
      subject: "New contact message from {{trigger.record.name}}"
      body: "{{trigger.record.email}} wrote:\n\n{{trigger.record.message}}"

config for channel: email is { channel: email, to, subject?, body }. channel: webhook (or slack) takes { channel, url, body?, headers? }.

Approval steps (human-in-the-loop)

An approval step creates a pending approval, notifies the user on every channel they have (email, and Telegram when linked - with the approve/deny links included), and pauses the run at that step. The user answers via the emailed links, gipity approval list + gipity approval answer <ap_guid> <response> (use --deny "feedback" to reject with free-text edits), or the approval_answer tool. Approve resumes the run at the next step.

Config: { title, description?, response_type?, choices?, on_deny? }

The user's answer flows into template vars either way: {{gate.action}} (approved/rejected) and {{gate.human_response}}. Feedback pattern - let a denial carry edits instead of killing the run, with per-step condition: guards picking the path:

steps:
  - name: draft
    prompt: "Draft a reply to {{trigger.payload.tweet}}"
  - name: gate
    step_type: approval
    config: { title: "Post this reply?", response_type: text, on_deny: continue }
  - name: post_it
    step_type: webhook
    condition: "gate.action == approved"
    config: { url: "https://example.com/post", body: { text: "{{draft.result}}" } }
  - name: learn
    condition: "gate.action == rejected"
    prompt: "The user rejected the draft with this feedback: {{gate.human_response}}. Original: {{draft.result}}. Save what to do differently to memory so future drafts improve."
    tool_filter: [memory_write]

Still to design around:

Record triggers (record.*)

Three rules make or break a record-triggered workflow:

  1. table: is required. It names the records table the workflow fires on. A record.* workflow without it is rejected at create/deploy time.

  2. Only Records API writes fire the trigger. The trigger fires on writes made through the Records API: the records_* agent tools, the REST endpoints (POST/PUT/DELETE /api/<appGuid>/records/<table>), and workflow record steps. A plain SQL INSERT from a function's db.query(...) does NOT fire it - if a function must trigger the workflow, have the frontend (or the function) write through the Records API instead of raw SQL. Every Records API write also carries provenance on the record's audit history (GET .../records/<table>/<id>/history, or gipity records history <table> <id>): a source of MANUAL/API/WORKFLOW/FORM and an English summary line - a workflow record step is attributed to the workflow by name (e.g. deal-won-followup created deal task "Follow up: Acme won").

  3. Anonymous writers need a public table. Records tables default to auth_level: member (sign-in required). For a form submitted by anonymous visitors (e.g. a contact form), expose the table as public declaratively in gipity.yaml with a records phase - placed AFTER the database phase, since the table must exist before it can be configured:

# gipity.yaml - after the database phase
- name: records
  type: records
  tables:
    - table: contact_messages
      auth_level: public   # public = anonymous writes; default is member (sign-in)

gipity deploy reconciles this every time (idempotent) - no agent tool, no gipity chat. To flip an existing table without a redeploy, use the CLI: gipity records config contact_messages --auth public.

A public form needs no kit and no backend code. Create the table in a migration, declare it in the records phase with auth_level: public, and the Records API above handles the validated, audited write.

Frontend write that fires the trigger (no SDK helper yet - mint the app's public token, then POST):

// APP_GUID = the project guid (the same value as the SDK script tag's data-app)
const t = await fetch('https://a.gipity.ai/api/token', {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ app: APP_GUID }),
}).then(r => r.json());
await fetch('https://a.gipity.ai/api/' + APP_GUID + '/records/contact_messages', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-App-Token': t.data.token },
  body: JSON.stringify({ name, email, message }),
});

Template variables

Plan limits

Webhook Triggers

# workflows/print-report.yaml - log each job a 3D printer POSTs to the webhook URL
name: print-report
trigger: webhook
steps:
  - name: log_job
    step_type: record
    config:
      op: create
      table: print_jobs
      data: { job: "{{trigger.payload.job}}", duration: "{{trigger.payload.duration}}", ok: "{{trigger.payload.ok}}" }

Management

Verifying a run executed

Related