Autopilots
Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
Autopilots let agents start work automatically on a schedule — configure a cron expression and a timezone, and AACWorkflow dispatches a task on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths (assigning, @-mention, and chat, where you are the one kicking things off), the core difference with Autopilots is that they are time-driven.
Configure an autopilot
Create a new autopilot on the workspace's Autopilot page. You set:
- Name — display name
- Agent — who the run is dispatched to
- Priority — inherited by the
taskit produces (same semantics as issue priority) - Description / prompt — the work description the agent receives each run
- Execution mode — see below
- Triggers — at least one
schedule(cron + timezone) orwebhook
Pick an execution mode
An autopilot has two execution modes. Start with "create issue" mode.
- Create issue mode (
create_issue) — default, recommended. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder,{{date}}, which interpolates to the UTC date inYYYY-MM-DDformat; any other{{...}}token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue. - Run-only mode (
run_only) — skips issue creation and enqueues ataskdirectly. The run is invisible on the board — you can only see it in the autopilot's run history.
Run it on a schedule
Every autopilot needs at least one schedule trigger. Cron uses the standard 5-field format (minute hour day month weekday), with 1-minute minimum granularity (no seconds). Timezone is IANA-formatted (for example, Asia/Shanghai) and determines which timezone the cron expression is interpreted in.
A few examples:
0 9 * * 1-5,Asia/Shanghai— 9 AM Beijing time on weekdays*/30 * * * *,UTC— every 30 minutes0 3 * * *,UTC— every day at 3 AM UTC
The AACWorkflow server scans for due triggers every 30 seconds — the actual fire time can lag by up to 30 seconds, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
Trigger once manually
To avoid waiting for cron while debugging an autopilot, trigger it manually:
- UI: click "Run now" on the autopilot detail page
- CLI:
aacworkflow autopilot trigger <autopilot-id>A manual trigger goes through the exact same execution flow as a schedule trigger — only the source field on the run record is marked manual.
Trigger from a webhook
Autopilots can also fire on inbound HTTP webhooks. Add a Webhook trigger on the autopilot detail page; AACWorkflow generates a unique URL of the shape:
https://<your-aacworkflow-host>/api/webhooks/autopilots/awt_…POST any JSON to that URL. AACWorkflow first stores a durable delivery and
synchronously admits an idempotent run, returning 200 OK with
status = accepted | skipped and its run_id. A database-backed worker then
resumes accepted runs, stores the body as the run's trigger_payload, and
dispatches the agent exactly the way a schedule trigger would. A server restart
between admission and dispatch does not lose the queued delivery.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'In create issue mode, the inbound payload is appended to the new issue's description so the agent can read it inline. In run-only mode, the payload is part of the run context the daemon hands the agent.
Payload shape
You can send your own envelope:
{ "event": "github.pull_request.opened", "eventPayload": { } }…or any JSON object/array. AACWorkflow normalizes it into an internal envelope:
{
"event": "<inferred>",
"eventPayload": <your body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}When you don't provide an event field, AACWorkflow infers it from common
headers and body fields (X-GitHub-Event + body action,
X-Gitlab-Event, X-Event-Type, body event/type/action). When
nothing matches, the event is webhook.received.
When configuring GitHub or similar sources, set the content type to
application/json — form-encoded webhook payloads are not accepted.
GitHub deliveries are deduplicated by X-GitHub-Delivery; generic senders can
provide Idempotency-Key. Retries with the same key reuse the original queued
delivery and run. Without either header, delivery remains best-effort because
there is no stable event identity to deduplicate.
Event filters
A new webhook trigger fires on every inbound POST, which is fine for a
single-purpose URL but noisy for sources that fan out many event types
(GitHub being the obvious one — a single repo webhook can deliver
push, pull_request, workflow_run, check_suite, and more). The
Event filters section on a webhook trigger lets you restrict which
events actually dispatch a run; everything else is recorded in delivery
history with status = ignored and reason = event_filtered, and no
run or issue is created.
Each row is one rule: an event name plus an optional comma-separated actions list. AACWorkflow allows a webhook if any row matches; leave the section empty to accept everything (the pre-filter behavior).
Examples:
| Event name | Actions | Matches |
|---|---|---|
workflow_run | completed, failed | workflow_run events with action: completed or action: failed only |
workflow_run | (empty) | every workflow_run event, regardless of action |
push | (empty) | every push event |
Where the event name and action come from
AACWorkflow derives the event name and action from the inbound request
in this order — the first match wins.
1. Body envelope. If the body is a JSON object with a string
event field, that value is the event name directly. An optional
eventPayload object then supplies action candidates from its
action / state / conclusion / status fields.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# inferred: event = trigger, action candidate = true2. Headers. When no body envelope is present, AACWorkflow reads the following well-known provider headers:
X-GitHub-Event: <event>— combined with the top-level bodyactionfield (when present) to formgithub.<event>.<action>.X-Gitlab-Event: <event>— becomesgitlab.<event>.X-Event-Type: <event>— passed through verbatim.
# GitHub-style: header gives the event name, body gives the action.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# inferred: event = github.workflow_run.completed
# → matches a filter row of workflow_run / completed
# Generic event-type header — no body fields needed.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# inferred: event = trigger.true → matches trigger / true3. Body fallback. If neither a body envelope nor a known header is
present, AACWorkflow falls back to top-level body string fields in this
order: event → type → action.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# inferred: event = trigger (from `type`), action candidate = true4. Default. If nothing above matches, the event is
webhook.received and there are no action candidates.
Action candidates, in full. Once the event is determined, AACWorkflow considers every value below as a possible action match:
- The event-name suffix, when the event has the form
provider.event.<action>(e.g.github.workflow_run.completed→completed). - The body fields
action,state,conclusion, andstatus— only when they are JSON strings. A boolean ({"action": true}) or a number does not qualify, so a filter expectingevent=trigger, action=truewill never match a body of{"trigger": true}becausetrueis a bool, not a string.
Common gotcha. A filter row like Event name: trigger /
Actions: true does not mean "fire when the body has
trigger: true" — Event filters match the inferred event and
action, not arbitrary body fields. Send trigger.true via
X-Event-Type (or use the body envelope shown above) to hit it.
Surrounding whitespace in saved filter rows (" workflow_run ") is
stored verbatim and will never match — trim before saving.
Quick test
Once a filter is configured, you can confirm both branches with curl:
# Allowed — header drives event=workflow_run, body drives action=completed
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", "delivery_id":"…", "run_id":"…", ...}
# Filtered — same event, action not in allowlist
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}Provider examples
The webhook trigger is provider-agnostic: it accepts any JSON body, so
the same URL works for GitHub, error trackers, billing events, CI/CD,
uptime monitors, and RSS bridges. Each example below shows the inbound
payload, the event filter rows to configure (leave empty to accept
everything), and what the agent receives as trigger_payload.
GitHub — pull request opened
GitHub sends the event type in the X-GitHub-Event header and the
sub-event in the body action field, so the inferred event is
github.<event>.<action>.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'X-GitHub-Event: pull_request' \
-H 'Content-Type: application/json' \
-d '{"action":"opened","pull_request":{"number":42,"title":"Add billing webhook"}}'
# inferred: event = github.pull_request.opened| Event name | Actions | Fires on |
|---|---|---|
pull_request | opened | new PRs only (not edits/closes) |
check_suite | completed | a CI run finishing on a PR |
Use create issue mode so each opened PR becomes an issue the agent reviews.
Sentry — issue alert
Sentry alert webhooks carry no provider header, so add an event
envelope (or let it fall back to webhook.received). Point the Sentry
alert action at the autopilot URL.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{
"event": "sentry.issue_alert",
"eventPayload": {
"action": "triggered",
"data": { "issue": { "title": "TypeError: cannot read x", "culprit": "api/handler.go" } }
}
}'
# inferred: event = sentry.issue_alert, action candidate = triggered| Event name | Actions | Fires on |
|---|---|---|
sentry.issue_alert | triggered | new alert firings only |
The agent gets the stack-trace title and culprit in the payload — pair it with create issue mode to auto-file a bug.
Stripe / Lago — billing event
Stripe puts the event name in the body type field; Lago uses
webhook_type. For Stripe, the body fallback infers the event from
type. For Lago, wrap it in an envelope so the event name is explicit.
# Stripe — body fallback reads `type`
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"invoice.payment_failed","data":{"object":{"customer":"cus_123"}}}'
# inferred: event = invoice.payment_failed
# Lago — explicit envelope
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"lago.invoice.payment_failure","eventPayload":{"status":"failed"}}'
# inferred: event = lago.invoice.payment_failure, action candidate = failed| Event name | Actions | Fires on |
|---|---|---|
invoice.payment_failed | (empty) | every Stripe payment failure |
lago.invoice.payment_failure | failed | Lago payment failures |
Deploy failed — CI/CD pipeline
Most CI providers can POST a custom JSON body on pipeline completion.
Use the event + eventPayload.status envelope so you can filter on
success vs. failure.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{
"event": "deploy",
"eventPayload": { "status": "failed", "environment": "production", "commit": "1a2b3c" }
}'
# inferred: event = deploy, action candidate = failed| Event name | Actions | Fires on |
|---|---|---|
deploy | failed | failed deploys only (not ok) |
Uptime alert — monitor down
Uptime services (UptimeRobot, Better Stack, Pingdom) post a small JSON
body. Add an event envelope and filter on the alert state.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"uptime","eventPayload":{"status":"down","monitor":"api.example.com"}}'
# inferred: event = uptime, action candidate = down| Event name | Actions | Fires on |
|---|---|---|
uptime | down | down alerts only |
Pair with run-only mode so the agent investigates without filing a duplicate incident issue on every flap.
RSS monitor — new item
There's no native RSS support, but a tiny scheduled job (a cron, GitHub Action, or Zapier/n8n step) can poll a feed and POST each new item to the webhook URL.
curl -X POST "$AACWORKFLOW_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{
"event": "rss.item",
"eventPayload": { "title": "v2.0 released", "link": "https://example.com/blog/v2", "published": "2026-06-20T09:00:00Z" }
}'
# inferred: event = rss.item| Event name | Actions | Fires on |
|---|---|---|
rss.item | (empty) | every posted feed item |
Use create issue mode to turn each feed item into a tracked issue.
URL is a bearer secret
The generated URL is the credential. Anyone with it can fire the autopilot. Treat it like a token:
- Don't paste it into public issue threads, screenshots, or chat history.
- Rotate it if it leaks — click "Rotate URL" on the trigger row, or run
aacworkflow autopilot trigger-rotate-url <autopilot-id> <trigger-id>. The old URL stops working immediately. - For sources that require strong source authentication, wait for per-trigger HMAC signature verification; this v1 URL is bearer-only.
- Workspace members who can view the autopilot can read its webhook URLs for now — tighter per-role secret visibility is a follow-up.
Status-code semantics
AACWorkflow acknowledges an active, valid delivery only after both its delivery and idempotent run are durable:
200 {"status":"accepted","delivery_id":"…","run_id":"…","autopilot_id":"…","trigger_id":"…"}— admission succeeded and the worker owns recoverable dispatch.200 {"status":"skipped","delivery_id":"…","run_id":"…","reason":"…"}— admission produced a durable skipped run, for example when the assignee's runtime is offline.
Normal no-op and deduplication outcomes return 200 OK so providers do not
retry them indefinitely:
{"status":"duplicate","delivery_id":"…","run_id":"…"}— the provider's idempotency key was already admitted.run_idis omitted only if the original request ended before its run could be admitted; the worker will recover it.{"status":"ignored","reason":"trigger_disabled"}— the trigger is disabled.{"status":"ignored","reason":"autopilot_paused"}— the autopilot is paused.{"status":"ignored","reason":"autopilot_archived"}— the autopilot is archived.
Non-2xx responses cover real failures:
400— invalid JSON, scalar body, or empty body.401— a configured webhook signature is missing or invalid.404— unknown token ({"error":"webhook not found"}).413— payload exceeded 256 KiB.429— the high absolute IP ceiling was exceeded, or the IP accumulated too many unknown-token/invalid-signature attempts. Responses includeRetry-After. Valid admitted deliveries are not rejected by the worker's per-trigger budget; they remain queued until that budget is available.
Where the webhook URL comes from
On AACWorkflow Cloud the trigger response always includes an absolute
webhook_url (built from https://aacworkflow.com), and the UI shows a
ready-to-copy URL. AACWorkflow deliberately does not derive the public host
from Host / X-Forwarded-Host headers, so a spoofed request can't trick
the server into minting a webhook URL that points at an attacker-controlled
host.
View run history
Every trigger produces a run record, visible on the "History" tab of the autopilot detail page:
- Trigger source (
schedule/manual/webhook) - Start time, completion time
- Status (
issue_created/running/completed/failed/skipped) - The linked issue (create issue mode) or
task(run-only mode) - Failure reason (if failed or skipped)
What happens when an autopilot fails
Autopilot failures are not auto-retried and do not send inbox notifications. A failure leaves a failed entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the autopilot is periodic, the next cron fire will trigger a new run, but the failed work is not automatically re-run.
If an autopilot is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
Why no auto-retry: autopilots are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
What's not yet available
API-kind triggers are not wired up. The trigger schema reserves an api
kind, but no ingress route fires it; the UI shows a Deprecated badge for
existing rows and offers no copy/rotate affordances. Per-trigger HMAC
signature verification, IP allowlists, and provider-specific event presets
are tracked as follow-ups; v1 URLs are bearer-only.
Next
- Assign issues to agents — a one-shot hand-off of an issue to an agent
- @-mention agents in comments — pull an agent in to take a look from a comment
- Chat — one-to-one conversation outside any issue