{
  "name": "workflow",
  "title": "Workflow Guide",
  "description": "When and how to create automated workflows with scheduling and human approval",
  "guid": "sk_plat_wfgd",
  "category": "Agent Tools",
  "requiredTools": [
    "workflow_create",
    "workflow_list",
    "workflow_run"
  ],
  "content": "# Workflow Guide\n\nWorkflows 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).\n\n## When to Suggest Workflows\n- Recurring scheduled tasks (\"every day\", \"every week\", \"every morning\")\n- Monitoring or alerting (\"watch this site\", \"check prices weekly\")\n- Batch operations that repeat (\"weekly report\", \"daily digest\")\n- Multi-step pipelines with human approval checkpoints\n\n## When NOT to Use Workflows\n- Programming, building, coding, or one-off tasks - do those directly with your coding agent\n- A coding agent has full context and adapts to errors, which workflows cannot do\n- 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 (see `app-development`).\n\n## How Workflows Work\n- Steps run in order on the server. Deterministic steps (function, record, notify, ...) cost no tokens; `llm` steps 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\n- This makes them reliable for repetitive automation but unsuitable for creative or complex reasoning tasks\n\n## Limits\n- **Max steps per workflow**: 20\n- **Max tool iterations per step**: 10\n- **Webhook rate limit**: 10 requests per minute\n- **Approval expiry**: default 7 days, max 30 days\n\n## Triggers\n- `trigger: schedule` - runs on a recurring `cron` expression (see below). `trigger: cron` is accepted as an alias and behaves identically.\n- `trigger: manual` - runs only when triggered by hand (the default)\n- `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)\n- `trigger: record.after_insert` / `record.after_update` / `record.after_delete` - runs on a data change. Requires `table:` (the records table to watch) - rejected at create/deploy time without it. See \"Record triggers\" below for what counts as a data change.\n- An unknown trigger is rejected at create time, not silently ignored\n\n## Scheduled Workflows\n- Use `trigger: schedule` with a `cron` expression - the same split as GitHub Actions (`on: schedule: - cron: \"...\"`)\n- `0 8 * * *` = daily at 8am\n- `0 9 * * 1` = Monday at 9am\n- Steps pass data via `{{step_name}}` template variables\n\n## Authoring in a YAML file (deployable)\n- Put the workflow in `workflows/<name>.yaml` and deploy. That's it - `gipity deploy` picks the directory up on its own (adding the phase to `gipity.yaml` for you), creates/updates the workflow (create-if-missing, update-if-changed) and arms the schedule. This keeps workflows reproducible and reviewable in the repo.\n- Shape: `name`, `description?`, `trigger` (or `trigger_type`), `cron` (for schedule triggers), `table` (for record.* triggers), `timezone?`, `system_prompt?`, `steps: [{ name, step_type, prompt?, config?, tools?, system_prompt?, ... }]`\n- Workflows deployed this way are owned by the project, so `step_type: function` and `record` steps get full project context at run time - including the project database. A scheduled `function` step can call `db.query(...)` exactly like the same function does over HTTP. **Call your own functions with a `function` step, not an HTTP `webhook` hop** - it's faster and gets the same `db`/`secrets`/`fetch` services.\n- **`llm` steps need nothing extra to run** - no agent, no setup. See \"LLM steps\" below for the model, tools and system prompt.\n\n```yaml\n# workflows/post-ai-comment.yaml\nname: ai-commenter\ntrigger: schedule\ncron: \"* * * * *\"\nsteps:\n  - name: post_comment\n    step_type: function\n    config: { name: post-ai-comment }\n```\n\n## Step types\nEach 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:\n- `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 bare `step.field` is `undefined` and a condition on it silently evaluates false (the step just `skip`s).\n- `notify` - send email or POST a webhook (see below).\n- `record` - CRUD on a records-kit table: `config: { op: create|read|update|delete, table, pk?, where?, data? }`.\n- `condition` - branch: `config: { expr, then?, else? }` (e.g. `expr: \"check.count > 0\"`). `then`/`else` name a LATER step to jump to - the steps in between are skipped. Omit them to just record the result for later `condition:` fields.\n- `timer` - wait: `config: { delay_seconds }` or `config: { until: <ISO datetime> }`, max 24h.\n- `approval` - pause for human approval (see below).\n- `webhook` - POST to an external URL: `config: { url, body?, headers? }`.\n- `workflow` - call another workflow: `config: { name, body?, mode? }` (see \"Composing workflows\" below).\n\n## LLM steps\nAn `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.\n- **`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.\n- **`tools`** - the only tools the step may call. Omit for a plain model call.\n  - `db_query` - SELECT/INSERT/UPDATE/DELETE on the project's database (name the `database` when the project has more than one). Schema changes (DROP, ALTER, CREATE, TRUNCATE, ...) are refused - they belong in migrations\n  - `records` - create/read/update/delete on a records table (same operations as a `record` step, so writes fire record triggers)\n  - `web_search` - search the web\n  - `web_fetch` - read a public page as text\n  - `http` - call an HTTPS API (method, headers, JSON body)\n  - `memory` - the project's memory: `list`, `read`, `write` or `delete` a topic. Persists across runs, and it's the same store as `gipity memory`.\n  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 hold `http` next to `db_query`/`records` unless 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.\n- **`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-level `system_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}}`.\n- `db_query`, `records` and `memory` work on the workflow's project, so the workflow must be project-owned (a `workflows/*.yaml` deploy, or `gipity workflow create` in a linked directory).\n- Retired keys fail loudly: `tool_filter` is now `tools`, and `skill_guid` and a top-level `agent:` are gone.\n\n```yaml\n# workflows/news-digest.yaml - each morning, summarize the day's news on a topic into the project database\nname: news-digest\ntrigger: schedule\ncron: \"0 8 * * *\"\nsystem_prompt: \"You write short, factual briefings. Cite a link for every claim.\"\nsteps:\n  - name: brief\n    prompt: \"Find the last day's news about home espresso. Save each item to the briefs table (title, url, summary), then return { count }.\"\n    tools: [web_search, web_fetch, db_query]\n```\n\n### Heartbeats (a check-in on a schedule)\nA 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`.\n\n## Error handling (`on_failure`)\nPer 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.\n\n## Fan-out (`foreach`)\nAny 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.\n\n## Composing workflows (`workflow` step)\n`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.\n\n## Sending email from an app (notify step)\nAn app emails the owner (or anyone) via a `notify` step - the platform sends it, no Gmail/OAuth and no LLM tokens:\n```yaml\n# workflows/contact-notify.yaml - email the owner on each new contact-form row\nname: contact-notify\ntrigger: record.after_insert\ntable: contact_messages\nsteps:\n  - name: email_owner\n    step_type: notify\n    config:\n      channel: email\n      to: owner@example.com\n      subject: \"New contact message from {{trigger.record.name}}\"\n      body: \"{{trigger.record.email}} wrote:\\n\\n{{trigger.record.message}}\"\n```\n`config` for `channel: email` is `{ channel: email, to, subject?, body }`. `channel: webhook` (or `slack`) takes `{ channel, url, body?, headers? }`.\n\n## Approval steps (human-in-the-loop)\nAn `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.\n\nConfig: `{ title, description?, response_type?, choices?, on_deny? }`\n- `response_type: yes_no` (default) - approve/deny. `choice` - user picks one of `choices` (max 10). `text` - free-form answer (edits, feedback).\n- `on_deny: fail` (default) - a denial fails the run. `on_deny: continue` - a denial is just an answer: the gate completes with `{{gate.action}} == rejected` and the user's words in `{{gate.human_response}}`, and later steps run.\n\nThe 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:\n```yaml\nsteps:\n  - name: draft\n    prompt: \"Draft a reply to {{trigger.payload.tweet}}\"\n  - name: gate\n    step_type: approval\n    config: { title: \"Post this reply?\", response_type: text, on_deny: continue }\n  - name: post_it\n    step_type: webhook\n    condition: \"gate.action == approved\"\n    config: { url: \"https://example.com/post\", body: { text: \"{{draft.result}}\" } }\n  - name: learn\n    condition: \"gate.action == rejected\"\n    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.\"\n    tools: [memory]\n```\n\nStill to design around:\n- **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_update` triggered workflow.\n- **The run waits indefinitely** until the approval is answered or the run is cancelled.\n\n## Record triggers (record.*)\nThree rules make or break a record-triggered workflow:\n\n1. **`table:` is required.** It names the records table the workflow fires on. A record.* workflow without it is rejected at create/deploy time.\n2. **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 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.\nEvery 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\"`).\n\n3. **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:\n```yaml\n# gipity.yaml - after the database phase\n- name: records\n  type: records\n  tables:\n    - table: contact_messages\n      auth_level: public   # public = anonymous writes; default is member (sign-in)\n```\n`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`.\n\n> **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.\n\nFrontend write that fires the trigger (no SDK helper yet - mint the app's public token, then POST):\n```js\n// APP_GUID = the project guid (the same value as the SDK script tag's data-app)\nconst t = await fetch('https://a.gipity.ai/api/token', {\n  method: 'POST', headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ app: APP_GUID }),\n}).then(r => r.json());\nawait fetch('https://a.gipity.ai/api/' + APP_GUID + '/records/contact_messages', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json', 'X-App-Token': t.data.token },\n  body: JSON.stringify({ name, email, message }),\n});\n```\n\n## Template variables\n- `{{step_name}}` - a prior step's whole output object; `{{step_name.field}}` / `{{step_name.list.0.field}}` - a field. `step_name` is the step's `name` lowercased with non-alphanumerics as `_`.\n- **An `llm` step'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.\n- **A `function` step nests its return under `.result`** - reach a returned field as `{{step_name.result.field}}` (same in a `condition:`). `record` steps return `{ record }` / `{ records, total }`; `approval` steps expose `{{gate.action}}` / `{{gate.human_response}}`. When unsure of a step's exact output shape, run it once and read the step output in `gipity workflow runs <name> <run_guid>`.\n- **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 a `function` step's handler reads `ctx.body.drafts` as a real array: never `JSON.parse` it. Embedded in longer text (`\"found {{scan.count}} posts\"`) it interpolates as text; `prompt:` is always text.\n- On a `record.*` trigger: `{{trigger.record.<field>}}` (the changed row), `{{trigger.old_record.<field>}}`, `{{trigger.operation}}`, `{{trigger.table}}`.\n\n## Plan limits\n- `maxWorkflows` active workflows per plan; scheduled workflows also have a `minCronIntervalHours` floor (free plan: 24h - `0 9 * * *` ok, `*/15 * * * *` rejected). Both are enforced identically whether you create with `gipity workflow create` or `gipity deploy` of a `workflows/*.yaml`. Check yours with `gipity plan`.\n\n## Webhook Triggers\n- A `trigger: webhook` workflow 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).\n- 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 an `x-webhook-secret` header instead of the query param).\n- **Both creation paths mint the secret.** `gipity workflow create` prints the URL on creation; `gipity deploy` of a `workflows/*.yaml` with `trigger: webhook` also 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).\n- **Get the URL anytime** with `gipity workflow info <name>` - it prints a `Webhook:` line, and `gipity workflow info <name> --json` includes a `webhook_url` field.\n- **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:\n\n```yaml\n# workflows/print-report.yaml - log each job a 3D printer POSTs to the webhook URL\nname: print-report\ntrigger: webhook\nsteps:\n  - name: log_job\n    step_type: record\n    config:\n      op: create\n      table: print_jobs\n      data: { job: \"{{trigger.payload.job}}\", duration: \"{{trigger.payload.duration}}\", ok: \"{{trigger.payload.ok}}\" }\n```\n\n## Management\n- `gipity workflow list` shows existing workflows; `gipity workflow runs <name>` shows run history\n- `gipity workflow run <name>` triggers one manually\n- `gipity workflow enable|disable <name>` turns its trigger on or off\n\n### Verifying a run executed\n- `gipity workflow run <name>` waits for the run and prints each step's status and output, exiting non-zero if it failed (`--no-wait` to fire and forget). With `--no-wait`, \"triggered\" means *accepted*, not *finished* - so confirm its outcome with `gipity workflow runs <name>`: each run shows `completed` / `failed` / `running` with 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.\n- Drill into one run with `gipity workflow runs <name> <wr_…>` to see each step's status and output (e.g. that a `notify` step actually sent, or what an `llm` step returned).\n- **`gipity workflow list` shows the linked project only** by default (not every project on your account) — pass `--all` to list workflows across all projects.\n- **A manual trigger sends no payload.** `gipity workflow run` doesn'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).\n\n## Related\n- [realtime-scheduled-app](realtime-scheduled-app.md) - recipe wiring a scheduled function-step poster into a live collaborative app, end-to-end"
}
