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 deploy. That's it -gipity deploypicks the directory up on its own (adding the phase togipity.yamlfor you), creates/updates the workflow (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.)
# 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. Every Records API write also carries provenance on the record's audit history (GET .../records/<table>/<id>/history, orgipity records history <table> <id>): asourceofMANUAL/API/WORKFLOW/FORMand an English summary line - a workflowrecordstep is attributed to the workflow by name (e.g.deal-won-followup created deal task "Follow up: Acme won").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.
A public form needs no kit and no backend code. Create the table in a migration, declare it in the
recordsphase withauth_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
{{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_.- An
llmstep's output is exactly the JSON object its prompt asked for - no wrapper, so{{step_name.field}}reaches it (a plain-text answer lands under.result, and{{step_name}}renders that text). Name the fields you want in the prompt. - 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>. - In a
config:value, a lone{{step.field}}keeps its real type - a list arrives as a list, an object as an object, a number as a number. So afunctionstep's handler readsctx.body.draftsas a real array: neverJSON.parseit. Embedded in longer text ("found {{scan.count}} posts") it interpolates as text;prompt:is always text. - 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
gipity workflow run <name>waits for the run and prints each step's status and output, exiting non-zero if it failed (--no-waitto fire and forget). Theworkflow_runtool triggers in the background - "triggered" means accepted, not finished - so confirm its 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