Workflows are automated multi-step sequences that run on a schedule, a webhook, a record change, or on demand. Author one as a workflows/<name>.yaml file and let gipity deploy reconcile it (or gipity workflow create --from workflows/<name>.yaml to create it without a full deploy; gipity workflow create --heartbeat scaffolds a daily check-in).
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 - do those directly with your coding agent
- A coding agent 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 run in order on the server. Deterministic steps (function, record, notify, ...) cost no tokens;
llmsteps are isolated model calls that only see their prompt, the template variables from prior steps, and the tools they declare - no conversation history, no agent - 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?,system_prompt?,steps: [{ name, step_type, prompt?, config?, tools?, system_prompt?, ... }] - 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. llmsteps need nothing extra to run - no agent, no setup. See "LLM steps" below for the model, tools and system prompt.
# 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).
LLM steps
An llm step (the default step_type) calls a model with its prompt and returns the JSON object the prompt asks for. It sees nothing else: no chat history, no agent, no memory unless you give it the memory tool.
model_id- a model id, a tier alias (small,fast,gipity-default, ...) or a provider name (openai,anthropic,gemini). Default: the platform's default model.tools- the only tools the step may call. Omit for a plain model call.db_query- SELECT/INSERT/UPDATE/DELETE on the project's database (name thedatabasewhen the project has more than one). Schema changes (DROP, ALTER, CREATE, TRUNCATE, ...) are refused - they belong in migrationsrecords- create/read/update/delete on a records table (same operations as arecordstep, so writes fire record triggers)web_search- search the webweb_fetch- read a public page as texthttp- call an HTTPS API (method, headers, JSON body)memory- the project's memory:list,read,writeordeletea topic. Persists across runs, and it's the same store asgipity memory. Give a step only what it needs. Pages and search results are untrusted text that can try to steer the model, so a step that reads the web (web_search,web_fetch) should not also holdhttpnext todb_query/recordsunless you trust every source - split reading and writing into separate steps instead. An unknown tool name is rejected at create/deploy time. A step gets up to 10 tool rounds (max_iterations) and fails, rather than returning a half answer, if it's still calling tools after that.
system_prompt- replaces the platform default ("You are running step X of the automated workflow Y. No person is watching this run...") for that step. A top-levelsystem_prompt:sets it for every llm step that doesn't have its own. The output rules (answer with one JSON object whose fields later steps read) are always appended after it, so a custom prompt can't break{{step.field}}.db_query,recordsandmemorywork on the workflow's project, so the workflow must be project-owned (aworkflows/*.yamldeploy, orgipity workflow createin a linked directory).- Retired keys fail loudly:
tool_filteris nowtools, andskill_guidand a top-levelagent:are gone.
# workflows/news-digest.yaml - each morning, summarize the day's news on a topic into the project database
name: news-digest
trigger: schedule
cron: "0 8 * * *"
system_prompt: "You write short, factual briefings. Cite a link for every claim."
steps:
- name: brief
prompt: "Find the last day's news about home espresso. Save each item to the briefs table (title, url, summary), then return { count }."
tools: [web_search, web_fetch, db_query]
Heartbeats (a check-in on a schedule)
A heartbeat is a scheduled workflow with one llm step and the memory tool: every run it looks at the project, compares with what it noted last time, and writes down what it saw. gipity workflow create --heartbeat scaffolds workflows/heartbeat.yaml (daily at 9am UTC) and creates it; edit the prompt and tools to taste, then gipity deploy or gipity workflow edit heartbeat --from workflows/heartbeat.yaml.
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, emails the user (with the approve/deny links included), and pauses the run at that step. The user answers via the emailed links, or gipity approval list + gipity approval answer <ap_guid> <response> (use --deny "feedback" to reject with free-text edits). 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 the 'drafting' memory topic so future drafts improve."
tools: [memory]
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:
gipity records, 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). 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 withgipity workflow createorgipity deployof aworkflows/*.yaml. Check yours withgipity plan.
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.
gipity 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
gipity workflow listshows existing workflows;gipity workflow runs <name>shows run historygipity workflow run <name>triggers one manuallygipity workflow enable|disable <name>turns its trigger on or off
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). With--no-wait, "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 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.
gipity 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