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>.yamlfile and letgipity deployreconcile 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_createis the chat-time tool form of the same thing.
When to Suggest Workflows
- Recurring scheduled tasks ("every day", "every week", "every morning")
- Monitoring or alerting ("watch this site", "check prices weekly")
- Batch operations that repeat ("weekly report", "daily digest")
- Multi-step pipelines with human approval checkpoints
When NOT to Use Workflows
- Programming, building, coding, or one-off tasks - handle those directly in chat
- The chat loop has full context and adapts to errors, which workflows cannot do
- A user-facing "run on demand" button in an app that needs the browser (current GPS location, a picked file, live form/DOM input) - workflows run server-side with no browser. Build those as a frontend button →
Gipity.fn()calling a function (seeapp-development).
How Workflows Work
- Steps are isolated LLM calls - they only see their prompt and template variables from prior steps, not conversation history
- This makes them reliable for repetitive automation but unsuitable for creative or complex reasoning tasks
Limits
- Max steps per workflow: 20
- Max tool iterations per step: 10
- Webhook rate limit: 10 requests per minute
- Approval expiry: default 7 days, max 30 days
Triggers
trigger: schedule- runs on a recurringcronexpression (see below).trigger: cronis accepted as an alias and behaves identically.trigger: manual- runs only when triggered by hand (the default)trigger: webhook- runs when an external HTTP request hits its URL (see "Webhook Triggers" below for how to get the URL - the deploy path mints the secret too)trigger: record.after_insert/record.after_update/record.after_delete- runs on a data change. Requirestable:(the records table to watch) - rejected at create/deploy time without it. See "Record triggers" below for what counts as a data change.- An unknown trigger is rejected at create time, not silently ignored
Scheduled Workflows
- Use
trigger: schedulewith acronexpression - the same split as GitHub Actions (on: schedule: - cron: "...") 0 8 * * *= daily at 8am0 9 * * 1= Monday at 9am- Steps pass data via
{{step_name}}template variables
Authoring in a YAML file (deployable)
- Put the workflow in
workflows/<name>.yamland add aworkflowsphase togipity.yaml;gipity deploythen creates/updates it (create-if-missing, update-if-changed) and arms the schedule. This keeps workflows reproducible and reviewable in the repo. - Shape:
name,description?,trigger(ortrigger_type),cron(for schedule triggers),table(for record.* triggers),timezone?,agent?,steps: [{ name, step_type, prompt?, config?, tool_filter?, ... }] - Workflows deployed this way are owned by the project, so
step_type: functionandrecordsteps get full project context at run time - including the project database. A scheduledfunctionstep can calldb.query(...)exactly like the same function does over HTTP. Call your own functions with afunctionstep, not an HTTPwebhookhop - it's faster and gets the samedb/secrets/fetchservices. - LLM steps run as an agent. Any workflow with an
llmstep needs an agent;gipity deployassigns your account's default agent automatically, so a deployed LLM workflow is runnable immediately - no extra step. To pin a specific agent, add a top-levelagent: <agent-short_guid-or-name>to the YAML. Deterministic-only workflows (function/record/notify/etc.) need no agent. (If you somehow have no default agent, the deploy fails the file with a clear message rather than shipping a workflow that silently never runs.)
# 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:
function- call an app function:config: { name, body? }. Gets full project context (db,secrets,fetch). Its output is wrapped: the function's return value is under.result(the step output is{ result, durationMs, logs }). So a later step references a returned field as{{step.result.field}}/condition: "step.result.field == ...", NOT{{step.field}}- a barestep.fieldisundefinedand a condition on it silently evaluates false (the step justskips).notify- send email or POST a webhook (see below).record- CRUD on a records-kit table:config: { op: create|read|update|delete, table, pk?, where?, data? }.condition- branch:config: { expr, then?, else? }(e.g.expr: "check.count > 0").then/elsename a LATER step to jump to - the steps in between are skipped. Omit them to just record the result for latercondition:fields.timer- wait:config: { delay_seconds }orconfig: { until: <ISO datetime> }, max 24h.approval- pause for human approval (see below).webhook- POST to an external URL:config: { url, body?, headers? }.workflow- call another workflow:config: { name, body?, mode? }(see "Composing workflows" below).
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? }
response_type: yes_no(default) - approve/deny.choice- user picks one ofchoices(max 10).text- free-form answer (edits, feedback).on_deny: fail(default) - a denial fails the run.on_deny: continue- a denial is just an answer: the gate completes with{{gate.action}} == rejectedand the user's words in{{gate.human_response}}, and later steps run.
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:
- One gate per run. A gate answers for the run as a whole. For an approve-each-item queue (review N drafted replies individually), write the items to a records table, build an approve/deny page over it, and put follow-up work in a
record.after_updatetriggered workflow. - The run waits indefinitely until the approval is answered or the run is cancelled.
Record triggers (record.*)
Three rules make or break a record-triggered workflow:
table:is required. It names the records table the workflow fires on. A record.* workflow without it is rejected at create/deploy time.- 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 workflowrecordsteps. A plain SQLINSERTfrom a function'sdb.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. - Anonymous writers need a
publictable. Records tables default toauth_level: member(sign-in required). For a form submitted by anonymous visitors (e.g. a contact form), expose the table aspublicdeclaratively ingipity.yamlwith arecordsphase - placed AFTER thedatabasephase, 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
recordskit, for a public form.gipity add recordsinstalls 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 therecordsphase.
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
{{step_name}}- a prior step's whole output object;{{step_name.field}}/{{step_name.list.0.field}}- a field.step_nameis the step'snamelowercased with non-alphanumerics as_.- A
functionstep nests its return under.result- reach a returned field as{{step_name.result.field}}(same in acondition:).recordsteps return{ record }/{ records, total };approvalsteps expose{{gate.action}}/{{gate.human_response}}. When unsure of a step's exact output shape, run it once and read the step output ingipity workflow runs <name> <run_guid>. - On a
record.*trigger:{{trigger.record.<field>}}(the changed row),{{trigger.old_record.<field>}},{{trigger.operation}},{{trigger.table}}.
Plan limits
maxWorkflowsactive workflows per plan; scheduled workflows also have aminCronIntervalHoursfloor (free plan: 24h -0 9 * * *ok,*/15 * * * *rejected). Both are enforced identically whether you create viaworkflow_createorgipity deployof aworkflows/*.yaml. Check yours with theplan_infotool.
Webhook Triggers
- A
trigger: webhookworkflow runs when an external service POSTs to its URL - give the service that URL and it can fire the workflow (e.g. a 3D printer that calls a web address when a job finishes). - Each webhook workflow has a secret; the URL shape is
https://a.gipity.ai/workflows/webhook/<workflow-guid>?secret=<secret>(the secret can also be sent as anx-webhook-secretheader instead of the query param). - Both creation paths mint the secret.
workflow_createprints the URL on creation;gipity deployof aworkflows/*.yamlwithtrigger: webhookalso generates one on first deploy (and never rotates it on later redeploys, so a URL you've already wired into an external service keeps working). - Get the URL anytime with
gipity workflow info <name>- it prints aWebhook:line, andgipity workflow info <name> --jsonincludes awebhook_urlfield. - The POSTed JSON body is available to steps as
{{trigger.payload.<field>}}(the same root sub-workflow calls use), so a workflow can parse what the caller sent:
# 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
- Use
workflow_listto show existing workflows and run history - Use
workflow_runto manually trigger one
Verifying a run executed
workflow_run/gipity workflow runtriggers the run in the background and returns immediately - the "triggered" message means accepted, not finished. To block until it finishes, rungipity workflow run <name> --wait(optional--timeout <s>, default 120): it polls the run and prints its terminal line (status, duration, tokens), exiting non-zero if the run failed - no need to hand-roll a polling loop. Otherwise confirm the outcome withgipity workflow runs <name>: each run showscompleted/failed/runningwith tokens and timing. A run that couldn't even start (no agent, no credits, no steps) is recorded as a failed run with the reason inline - so "no runs" genuinely means nothing was triggered, not that a run is silently stuck.- Drill into one run with
gipity workflow runs <name> <wr_…>to see each step's status and output (e.g. that anotifystep actually sent, or what anllmstep returned). gipity workflow listshows the linked project only by default (not every project on your account) — pass--allto list workflows across all projects.- A manual trigger sends no payload.
workflow_rundoesn't supply{{trigger.*}}values, so a workflow whose first step parses webhook/record trigger data will see empty/literal placeholders on a manual run. Manual triggering verifies step wiring and conditions; to exercise the real parse path, fire the actual trigger (POST the webhook URL with a sample body, or write a row for a record trigger).
Related
- realtime-scheduled-app - recipe wiring a scheduled function-step poster into a live collaborative app, end-to-end