---
title: Autopilots
description: Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
---

import { Callout } from "fumadocs-ui/components/callout";

Autopilots let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) 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](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/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 `task` it 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) or `webhook`

## 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 in `YYYY-MM-DD` format; 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 a `task` directly. 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 minutes
- `0 3 * * *`, `UTC` — every day at 3 AM UTC

The Multica 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:

```bash
multica 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; Multica generates a unique URL of the shape:

```
https://<your-multica-host>/api/webhooks/autopilots/awt_…
```

POST any JSON to that URL — Multica records a run with `source = webhook`,
stores the body as the run's `trigger_payload`, and dispatches the agent
exactly the way a schedule trigger would.

```bash
curl -X POST "$MULTICA_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:

```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```

…or any JSON object/array. Multica normalizes it into an internal envelope:

```json
{
  "event": "<inferred>",
  "eventPayload": <your body>,
  "request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```

When you don't provide an `event` field, Multica 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.

### 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. Multica 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

Multica 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.

```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d '{"event":"trigger","eventPayload":{"action":"true"}}'
# inferred: event = trigger, action candidate = true
```

**2. Headers.** When no body envelope is present, Multica reads the
following well-known provider headers:

- `X-GitHub-Event: <event>` — combined with the top-level body
  `action` field (when present) to form `github.<event>.<action>`.
- `X-Gitlab-Event: <event>` — becomes `gitlab.<event>`.
- `X-Event-Type: <event>` — passed through verbatim.

```bash
# GitHub-style: header gives the event name, body gives the action.
curl -X POST "$MULTICA_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 "$MULTICA_WEBHOOK_URL" \
  -H 'X-Event-Type: trigger.true' \
  -H 'Content-Type: application/json' \
  -d '{}'
# inferred: event = trigger.true → matches trigger / true
```

**3. Body fallback.** If neither a body envelope nor a known header is
present, Multica falls back to top-level body string fields in this
order: `event` → `type` → `action`.

```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d '{"type":"trigger","action":"true"}'
# inferred: event = trigger (from `type`), action candidate = true
```

**4. 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, Multica
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`, and `status` —
  **only when they are JSON strings**. A boolean (`{"action": true}`)
  or a number does not qualify, so a filter expecting
  `event=trigger, action=true` will never match a body of
  `{"trigger": true}` because `true` is 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`:

```bash
# Allowed — header drives event=workflow_run, body drives action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
  -H 'X-GitHub-Event: workflow_run' \
  -H 'Content-Type: application/json' \
  -d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}

# Filtered — same event, action not in allowlist
curl -X POST "$MULTICA_WEBHOOK_URL" \
  -H 'X-GitHub-Event: workflow_run' \
  -H 'Content-Type: application/json' \
  -d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```

### 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
  `multica 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

Multica returns `200 OK` with a `status` field for normal no-op outcomes so
your provider's webhook-retry machinery doesn't keep hammering the URL:

- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
  — a run was dispatched.
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
  — the assignee's runtime is offline; recorded as a `skipped` run.
- `{"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.
- `404` — unknown token (`{"error":"webhook not found"}`).
- `413` — payload exceeded 256 KiB.
- `429` — per-token rate limit exceeded (defaults to 60 req/min).

### Self-hosted: configure your public URL

When `MULTICA_PUBLIC_URL` is set on the server (e.g. `https://multica.example.com`),
the trigger response includes an absolute `webhook_url` and the UI shows a
ready-to-copy URL. Without it, the UI composes the URL from the client's
API origin — which is fine for desktop and same-origin web, but not for
custom self-hosted reverse proxies. Multica deliberately does not derive
the public host from `Host` / `X-Forwarded-Host` headers so a misconfigured
reverse proxy cannot trick the server into minting webhook URLs pointing 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

<Callout type="warning">
**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.
</Callout>

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**](/assigning-issues) — a one-shot hand-off of an issue to an agent
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
- [**Chat**](/chat) — one-to-one conversation outside any issue
