# Workflow Guide

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 (see `app-development`).

## How Workflows Work
- 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
- 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 recurring `cron` expression (see below). `trigger: cron` is 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. 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.
- An unknown trigger is rejected at create time, not silently ignored

## Scheduled Workflows
- Use `trigger: schedule` with a `cron` expression - the same split as GitHub Actions (`on: schedule: - cron: "..."`)
- `0 8 * * *` = daily at 8am
- `0 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>.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.
- 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?, ... }]`
- 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.
- **`llm` steps need nothing extra to run** - no agent, no setup. See "LLM steps" below for the model, tools and system prompt.

```yaml
# 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 bare `step.field` is `undefined` and a condition on it silently evaluates false (the step just `skip`s).
- `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`/`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.
- `timer` - wait: `config: { delay_seconds }` or `config: { 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 the `database` when the project has more than one). Schema changes (DROP, ALTER, CREATE, TRUNCATE, ...) are refused - they belong in migrations
  - `records` - create/read/update/delete on a records table (same operations as a `record` step, so writes fire record triggers)
  - `web_search` - search the web
  - `web_fetch` - read a public page as text
  - `http` - call an HTTPS API (method, headers, JSON body)
  - `memory` - the project's memory: `list`, `read`, `write` or `delete` a topic. Persists across runs, and it's the same store as `gipity 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 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.
- **`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}}`.
- `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).
- Retired keys fail loudly: `tool_filter` is now `tools`, and `skill_guid` and a top-level `agent:` are gone.

```yaml
# 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:
```yaml
# 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 of `choices` (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}} == rejected` and 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:
```yaml
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_update` triggered 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:

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: `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.
Every 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"`).

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:
```yaml
# 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 `records` phase with `auth_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):
```js
// 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_name` is the step's `name` lowercased with non-alphanumerics as `_`.
- **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.
- **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>`.
- **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.
- On a `record.*` trigger: `{{trigger.record.<field>}}` (the changed row), `{{trigger.old_record.<field>}}`, `{{trigger.operation}}`, `{{trigger.table}}`.

## Plan limits
- `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`.

## Webhook Triggers
- 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).
- 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).
- **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).
- **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.
- **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:

```yaml
# 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 list` shows existing workflows; `gipity workflow runs <name>` shows run history
- `gipity workflow run <name>` triggers one manually
- `gipity 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-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.
- 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).
- **`gipity workflow list` shows the linked project only** by default (not every project on your account) — pass `--all` to list workflows across all projects.
- **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).

## Related
- [realtime-scheduled-app](realtime-scheduled-app.md) - recipe wiring a scheduled function-step poster into a live collaborative app, end-to-end
