Automations Guide
Run AI workflows from webhooks or schedules — with one source of truth for the prompt, the tools, and the model.
Concepts
An automation is a recipe plus a trigger plus a small bit of per-instance config. The recipe owns the prompt, the MCP tool set, the execution tier, and (optionally) the model. The automation decides when the recipe runs and any per-call inputs the recipe needs.
Reusable bundle — prompt template, allowed MCP tools, execution tier, optional model pin.
When the recipe runs — a webhook URL or a cron schedule.
Per-automation JSON the prompt can substitute — project IDs, labels, audience names, anything.
One recipe can back many automations. You might write a single "Linear bug triage" recipe and point two automations at it — one webhook from Linear, one schedule for the weekly backlog walk. Same prompt, same tools, two different trigger paths.
Recipes
Workunit ships 14 system recipes in the Recipe library — reporting digests, ingestion adapters, quality-of-life nudges. You can write your own with the "New recipe" button on that tab.
A user recipe declares four things at save time: its execution tier (lightweight one-shot LLM call vs heavy cloud VM with opencode), which trigger types automations built on it may use, the MCP tools the agent is allowed to invoke, and a prompt template authored in Go's text/template syntax. The template is where {{.payload}} and {{.cfg.fieldName}} get substituted at run time — see the Prompt templates section below for the full reference.
Allowed-tool selection is for decision quality, not authorization
A model handed four relevant tools picks better than one given thirty unrelated ones. The OAuth scope set is the actual security boundary; tool allow-lists are about giving the agent a tight menu.
Pinning a model is optional
Leave the model blank and the run uses your org's default Automation model. Pin it when the recipe needs something specific (e.g. a model that's strong at structured extraction).
One recipe, many automations
The recipe author owns the prompt and the tool set. Each automation built on the recipe brings its own trigger and its own per-instance config values.
Triggers
Two trigger types, two different ways for a payload to reach your prompt. Both route through the same run pipeline — same MCP tools, same model selection, same spend accounting.
External services POST to a signed URL. Payload = whatever they send.
Cron expression. Payload = {scheduled_for: ISO timestamp}.
Webhook
A unique signed URL per automation. Anything POSTed to it is HMAC-verified, deduped, rate-limited, and (if accepted) enqueued as a run. Use this when an external service has webhook output (GitHub, Linear, Sentry, Stripe, your own forms).
Schedule
Standard 5-field cron expression. Use this for recurring rollups (weekly leadership digest, daily team summary) or polling external sources that don't push webhooks. The payload is empty {} — the recipe pulls its inputs via MCP tools.
Prompt templates
The prompt template is plain text plus two substitution sites. It uses Go's text/template package, so the syntax is the familiar double-brace mustache style — but the data the template sees is deliberately minimal. Two top-level fields, that's it.
Template context
When a recipe runs, the template is evaluated against a tiny data map. The only top-level keys are:
{{.payload}} — the trigger payload, pretty-printed as JSON
What the trigger handed in. Webhook deliveries pass the raw POST body re-indented with two-space JSON; schedules pass a one-key timestamp object.
{{.cfg.fieldName}} — per-automation config values
Whatever JSON the operator typed into the New-automation form's Config field, exposed key-by-key. The recipe author writes the form; each automation supplies the values.
There is no other top-level data. No .automation, no .recipe, no .trigger, no implicit timestamps, no template functions like now or json. If the recipe needs additional context — the current user, related workunits, your project's recent activity — it pulls that via MCP tools at run time. The template is the prompt, not the orchestrator.
The runner uses Go's missingkey=zero option. If you write {{.cfg.fieldName}} and the automation never set that field, the substitution becomes the empty string — the recipe still runs. This is on purpose: the recipe author owns the prompt and decides whether an unset value is meaningful or whether to wrap it in {{if .cfg.foo}}…{{end}}.
Payload by trigger
{{.payload}} is the same variable everywhere, but what it contains depends on the trigger type:
The exact JSON body the sender POSTed, re-indented with two-space pretty printing. If the body isn't valid JSON the runner falls back to the raw bytes as-is — never errors, but malformed senders won't get a pretty layout.
{
"event": "issue.created",
"issue": {
"id": "ISS-1234",
"title": "Login button broken on Safari",
"labels": ["bug", "frontend"]
}
}A tiny synthetic payload — just the ISO-8601 timestamp for the slot the run was scheduled for. The recipe is expected to fetch its real inputs via MCP tools (list_workunits, search, etc).
{
"scheduled_for": "2026-05-26T09:00:00Z"
}Gotchas
If you set {"count": 42} in the config and write {{.cfg.fieldName}}, the prompt sees 42 rendered as a string — fine for numbers and booleans. Nested objects and arrays render as map[...] / [...] Go-default forms, which is rarely what you want. Keep config values flat: strings, numbers, booleans.
Workunit uses Go's text/template, not html/template. JSON payloads containing <, >, or & pass through unchanged. That's the right default for LLM prompts.
A typo like an unclosed {{ in the template won't fail when you save the recipe — the form accepts any string. The first run with that template fails with a clear parse error in the run log, and subsequent runs keep failing until you fix it. Use the Test-fire button to validate after edits.
The New-automation form takes raw JSON for the Config field — there's no schema enforcement beyond "is it valid JSON?". The recipe's prompt copy should describe the fields it expects (e.g. audience_name, linear_team_id) so operators know what to fill in. Put that in the recipe Description so it's visible on the Recipe library card.
Worked example
A Linear-bug-triage recipe might look like this. The template uses both substitution sites — the payload for the actual issue data, the config to scope the triage to the right project and team.
You are triaging a Linear issue for the {{.cfg.team_name}} team
on the Workunit project {{.cfg.project_id}}.
The issue webhook payload is:
{{.payload}}
Steps:
1. Read the issue title and labels.
2. If the labels include "bug", create a Workunit under project {{.cfg.project_id}}
with the issue title as the problem statement.
3. {{if .cfg.notify_user}}Notify {{.cfg.notify_user}} once the workunit exists.{{end}}
4. Save a context atom on the new workunit summarising what you found.
Return a short JSON object with {workunit_id, action_taken}.An automation built on this recipe sets its Config to something like:
{
"team_name": "Frontend",
"project_id": "d44037bb-315f-43cb-a60e-b387b4ffeec4",
"notify_user": "[email protected]"
}When Linear POSTs an issue-created event, the recipe agent sees the rendered prompt with the issue's title, labels, and IDs interpolated alongside the config-supplied project_id and team_name — then it has the MCP tools it was allowed to call (create_workunit, search, save_context) to do the actual triage.
Webhook deep dive
Signature schemes
Every webhook endpoint at /hooks/<path_token> uses a signature scheme to verify requests. The scheme is set when the automation is created — pick a recipe matching your source (GitHub, Stripe, Linear, Sentry, Shopify) and the verifier is locked to that vendor's format. Custom senders use the Workunit-native scheme below.
The path_token is a 43-character base64url string the New-automation flow generates per automation and reveals once. Distinct from the row's internal id — rotating the HMAC secret doesn't change the URL, and the URL can be re-minted (by deleting + recreating the automation) without breaking internal audit references.
The signature must be computed over the exact bytes you POST. A proxy that re-serializes JSON whitespace will break the signature.
| Source | scheme key | Signed header | Encoding |
|---|---|---|---|
| GitHub | github | X-Hub-Signature-256 | hex |
| Stripe | stripe | Stripe-Signature | hex |
| Linear | linear | Linear-Signature | hex |
| Sentry | sentry | Sentry-Hook-Signature | hex |
| Shopify | shopify | X-Shopify-Hmac-Sha256 | base64 |
| Workunit-native (custom sender) | workunit_v1 | X-WU-Signature + X-WU-Timestamp | hex |
| Unsigned (path is the auth) | none | — | — |
GitHub
signature_scheme=githubRepository, organization, and app webhooks from github.com or GitHub Enterprise.
X-Hub-Signature-256 raw request body hex - Repo Settings → Webhooks → Add webhook.
- Paste Workunit's reveal-banner secret into the Secret field.
- Paste the /hooks/<path_token> URL into Payload URL.
- Content type: application/json (so the raw body is the JSON, not a form-encoded wrapper).
- GitHub also sends the older X-Hub-Signature (SHA-1). Workunit ignores it — only the SHA-256 header is checked.
Stripe
signature_scheme=stripeStripe events (charges, subscriptions, disputes, …) from the Stripe Dashboard.
Stripe-Signature <timestamp> + "." + <body> hex - Dashboard → Developers → Webhooks → Add endpoint.
- Paste the /hooks/<path_token> URL as Endpoint URL.
- Stripe assigns its own signing secret — copy it back into Workunit by rotating the secret on this automation.
- Pick the events you care about (e.g. customer.subscription.created, charge.dispute.created).
- Timestamp must be within ±5 minutes of server time — older or future-dated requests are rejected as timestamp_skew.
- During secret rotation Stripe attaches two v1 signatures, one per active secret. Workunit accepts the request if any v1 matches, so both old and new senders keep working through the rollover.
Linear
signature_scheme=linearLinear workspace events — issue created/updated, comment created, project status changes.
Linear-Signature raw request body hex - Linear → Settings → API → Webhooks → New webhook.
- Paste the /hooks/<path_token> URL as the endpoint.
- Linear shows you a signing secret — copy it back into Workunit by rotating the secret on this automation.
- Pick the resource types and actions you want.
- No timestamp in the signature header — Linear puts a webhookTimestamp field in the request body, but Workunit's verifier doesn't read it today. Replay protection comes from dedup: set dedup_jsonpath to $.data.id with a 24h window so a retried delivery doesn't fire a second run. The system recipe Linear → Workunit pre-configures this.
- Custom Linear automations without dedup are vulnerable to a captured-request replay attack. If that matters for your use case, configure dedup explicitly or stick to the system recipe.
Sentry
signature_scheme=sentrySentry issue alerts, metric alerts, and Custom Integration events.
Sentry-Hook-Signature raw request body hex - Sentry → Settings → Integrations → Webhooks (or create a Custom Integration).
- Paste the /hooks/<path_token> URL.
- Sentry shows a client secret — copy it back by rotating the secret on this automation.
- Pick the alert rules or event types you want delivered.
Shopify
signature_scheme=shopifyShopify Admin webhooks — orders, refunds, customer events, inventory changes.
X-Shopify-Hmac-Sha256 raw request body base64 - Shopify Admin → Settings → Notifications → Webhooks → Create webhook.
- Paste the /hooks/<path_token> URL.
- Format: JSON.
- Shopify shows a signing secret — copy it back by rotating the secret on this automation.
- Shopify encodes the HMAC as base64, not hex. If you wrote your own verifier against a hex-based example you'll see signature_failure_reason=wrong on every delivery — switch to base64.
Workunit-native (custom sender)
signature_scheme=workunit_v1Sign requests yourself when calling Workunit from your own service, a script, or another Workunit automation.
X-WU-Signature + X-WU-Timestamp <timestamp> + "." + <body> hex - Read the reveal banner once when creating the automation — both the secret and the URL are shown together and the secret is irrecoverable after.
- Sign requests with HMAC-SHA256(secret, ts + "." + body), hex-encode the result, attach as X-WU-Signature.
- Send the Unix-seconds timestamp as X-WU-Timestamp on the same request.
- Use the curl / Node / Python / Go snippets in the next sections as starting points.
- X-WU-Timestamp must be within ±5 minutes of the server's clock. Older or future-dated requests are rejected as timestamp_skew.
- The HMAC over (timestamp + body) is what stops replay. A true bit-for-bit retransmit at the same timestamp falls inside the skew window — rotate the secret if you suspect a leak.
- Rotating the secret mints a new value atomically (no overlap window) — deploy the new value to your sender first, then rotate, then verify.
Unsigned (path is the auth)
signature_scheme=noneLast-resort scheme when the sender genuinely can't sign requests.
— — — - Pick this scheme only with another auth layer in front — typically an IP allowlist at your edge proxy.
- Treat the /hooks/<path_token> URL like a secret. It is the only thing standing between an internet stranger and your automation.
- The HMAC secret on the row is unused; rotating it does nothing.
- signature_valid=true on every accepted delivery for this scheme — the audit row still records the scheme so you can tell unsigned and signed traffic apart in the Webhook deliveries panel.
Signing — curl
# Replace PATH_TOKEN and SECRET with the values from your automation row.
PATH_TOKEN=your-43-char-base64url-path-token
SECRET=your-hmac-secret
BODY='{"ping":"pong"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "https://workunit.app/hooks/$PATH_TOKEN" \
-H "Content-Type: application/json" \
-H "X-WU-Timestamp: $TS" \
-H "X-WU-Signature: $SIG" \
--data-raw "$BODY"Signing — Node.js
// Node.js 18+ (uses the built-in fetch + crypto modules).
import { createHmac } from 'node:crypto';
const pathToken = 'your-43-char-base64url-path-token';
const secret = 'your-hmac-secret';
const body = JSON.stringify({ ping: 'pong' });
const ts = Math.floor(Date.now() / 1000).toString();
const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
const res = await fetch(`https://workunit.app/hooks/${pathToken}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WU-Timestamp': ts, 'X-WU-Signature': sig },
body,
});
console.log(res.status, await res.text());Signing — Python
# Python 3.10+
import hmac, hashlib, json, time, urllib.request
path_token = "your-43-char-base64url-path-token"
secret = b"your-hmac-secret"
body = json.dumps({"ping": "pong"}).encode()
ts = str(int(time.time())).encode()
sig = hmac.new(secret, ts + b"." + body, hashlib.sha256).hexdigest()
req = urllib.request.Request(
f"https://workunit.app/hooks/{path_token}", data=body, method="POST",
headers={"Content-Type": "application/json", "X-WU-Timestamp": ts.decode(), "X-WU-Signature": sig},
)
with urllib.request.urlopen(req) as resp:
print(resp.status, resp.read().decode())Signing — Go
package main
import (
"bytes"; "crypto/hmac"; "crypto/sha256"; "encoding/hex"; "fmt"
"net/http"; "strconv"; "time"
)
func main() {
pathToken, secret := "your-43-char-base64url-path-token", []byte("your-hmac-secret")
body := []byte(`{"ping":"pong"}`)
ts := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, secret); mac.Write([]byte(ts + ".")); mac.Write(body)
sig := hex.EncodeToString(mac.Sum(nil))
req, _ := http.NewRequest("POST", "https://workunit.app/hooks/"+pathToken, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-WU-Timestamp", ts); req.Header.Set("X-WU-Signature", sig)
resp, _ := http.DefaultClient.Do(req); defer resp.Body.Close()
fmt.Println(resp.Status)
}HTTP status codes
202 Accepted— delivery persisted; run enqueued (or deduped/rate-limited per the automation's own settings — see the response body)401 Unauthorized— signature missing, wrong, outside the timestamp skew window, OR the path_token doesn't exist. Collapsed deliberately so the response can't be used to enumerate which tokens exist — the precise failure mode is recorded in the delivery audit row for the owner to debug.413 Payload Too Large— body exceeded the 256 KB cap (rejected pre-R2 write)429 Too Many Requests— the endpoint hit the pre-signature ceiling of 60 requests/minute. The cap is a fixed DoS guard and applies BEFORE signature verification, so a flood of bad-signature requests trips it too. Back off ~1 minute and retry; legitimate senders rarely exceed it.503 Service Unavailable— transient backend hiccup (e.g. R2 storage outage). Honor theRetry-Afterheader.
Dedup & rate limits
There are three independent ceilings on a webhook endpoint, applied in this order:
- Pre-signature ceiling (60/min, fixed) — any request, valid or not, counts. Trips return 429 BEFORE signature verification and don't write an audit row. This is a DoS guard, not a tunable.
- Dedup — JSONPath + window seconds, configured per automation. Matching deliveries are accepted (audit row + R2 write) but don't fire a run. Set a JSONPath like
$.event.idand a window like 86400 to suppress repeats of the same external event for 24 hours. - Per-automation rate limit (runs/minute, tunable) — counts every delivery that LED to a run. Trips return 202 with
rate_limit_dropped: truein the response body — the sender's retry queue is satisfied but no run fires. Set this to whatever your downstream workflow can absorb.
The pre-signature ceiling exists because every bad-signature request still costs a DB INSERT for the audit row, and a known endpoint UUID is enough for an attacker to amplify writes. 60/min is well above any normal webhook source's cadence (Stripe, GitHub, Linear, Sentry all batch into low tens per minute). If a legitimate sender sustains higher than that, talk to us; raising the ceiling is a code change.
Test-fire (the button on each automation row) has its own modest cap of 10 fires per minute per automation — admin sessions still cost model spend, and the cap stops accidental scripts. Use the real webhook path for load testing.
Cost & models
Every recipe can pin its own model. When it doesn't, the run uses the organization's default automation model — set on /organization/settings under "Automation model". This is intentionally distinct from the writer model used for homepage digests and recaps so the two can be tuned independently.
Cost is read from each OpenRouter response and persisted per-run as USD with 6 decimal places of precision. The Automations page right rail breaks 7-day spend down by model. Sub-cent runs render as <$0.01 — that's precise enough for the row; the per-model rollup shows the actual sum.
Heavy-tier runs (opencode in a cloud VM) don't currently surface their OpenRouter spend back to Workunit — they appear in the run log with cost $0 and the per-model spend rollup undercounts. The spend panel shows an asterisk + count when heavy-tier runs are in the window so the gap is visible. We're tracking the upstream fix; the workaround for now is to budget heavy-tier spend separately in your OpenRouter account view.
Data export
Automations, webhook endpoints, webhook deliveries (with the HMAC secret scrubbed), and automation runs are all included in your user data export. Request one from your account settings under "Data".
Bring your AI agents and your team into one workspace
Workunit gives your agents structured context and your team a shared place to plan, track, and ship the work. Free to start, no credit card.
Next steps
Ready to build one? Open the Recipe library, pick a starting point, and adapt the template. Or read on for the related guides.
The tools your recipes will call — what each one does and the OAuth scope it requires.
Context atoms, multi-model handoff, and how the agent picks up where another left off.
What heavy-tier runs look like under the hood — opencode in a sandboxed VM.
Prompt-writing patterns and recipe design tips that hold up across many automations.
The Test-fire button on each automation row replays a sample payload through the live template — use it to debug substitutions without waiting for a real webhook.