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)

# gipity.yaml
version: 1
deploy:
  phases:
    - name: workflows
      type: workflows
      source: workflows
# 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.
  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.

Use the native Records API, NOT the records kit, for a public form. gipity add records installs the records kit - a signed-in member CRUD data plane that requires sign-in and has no public level. It is the wrong tool for an anonymous contact form. The native Records API above needs no kit: just create the table in a migration and declare the records phase.

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