# Autopilot

> Let Selfwin write and publish up to five articles a night for the prompt where AI answers miss you, then measure whether the prompt moved.

- Product: Selfwin
- Section: Take action
- Updated: 2026-10-30
- Canonical: https://docs.selfwin.co/autopilot
- Keywords: Autopilot, AI SEO autopilot, auto publish blog, listicle, GEO content, WordPress, Webflow, Ghost, Lovable blog

Autopilot closes the loop between tracking and content. Every night, after that night's answers are collected and analyzed, it:

1. **Measures** every article it already published: for that article's target prompt, how many answers named you, your best position, and whether the engines cited the article itself.
2. **Takes the next writing task** from the Tasks queue. The GEO agent alone decides which prompts need content: it diagnoses every prompt from fresh answers and queues a "create a page" task where you are missing, or an "update this page" task where your page is cited but you still trail. Prompts you already win get no task, so Autopilot writes nothing for them. Updates are done automatically only for articles Autopilot published on a destination that replaces posts in place (the Selfwin blog or a Git repo), and only in publish mode; other updates stay with you. A prompt with an existing article is not given another new page. Failed delivery retries the same approved article.
3. **Chooses a keyword**: with an OpenSEO key, the highest-volume winnable keyword among the prompt and its fanouts; otherwise the search the engines themselves ran while answering.
4. **Writes** a listicle with the seo2 pipeline: research from the pages the engines cite, an outline, the article, an FAQ from real fanout searches, and a lint pass.
5. **Publishes** to your connected blog, or holds the article for your approval.

## Modes

- **Hold for approval**: the article waits on the Autopilot page. Review it and press Publish.
- **Publish automatically** (enabled when you connect your first working blog): the article goes live as soon as it passes the gates below.

Autopilot writes nothing on a day OpenSEO or Exa is unavailable: before it picks a prompt, it checks that both answer, and it stops if either key is missing, refused, or out of credits. The reason appears on the Autopilot page until the next article is written.

Even in automatic mode, an article is held for review when its research was incomplete (no OpenSEO keyword data, no Exa search results, or no fanout searches from the engines), when the linter fails, or when it would name a product that neither you track nor the engines mentioned for that prompt. Links that are not your own domain or one of the research sources are turned into plain text before publishing, so a model can never invent a URL on your site.

## Destinations

| Destination | Covers | You need |
| --- | --- | --- |
| WordPress | WordPress.org, WordPress.com Business, WP Engine, Kinsta | Site URL, username, an application password |
| Ghost | Ghost(Pro), self-hosted Ghost | Site URL, a custom integration Admin API key |
| Webflow | Webflow CMS | Collection ID and a site API token with CMS write |
| Shopify | Store blogs | Store domain, blog ID, an Admin API token with `write_content` |
| GitHub | Next.js, Astro, Hugo, Jekyll, Eleventy, Docusaurus, Lovable via GitHub sync | Repository, posts folder, a fine-grained token with Contents write |
| Notion | Super, Feather, Potion, Notion Sites | Database ID and an integration secret, with the database shared to it |
| Webhook | Lovable and Supabase, Next.js, Framer or Wix through Zapier or Make, any headless CMS | An HTTPS endpoint and a signing secret |

Selfwin tests every destination when you add it and records the result. Credentials are encrypted at rest and never shown again. Destinations must be public HTTPS addresses.

## The webhook payload

Selfwin sends `POST` with `Content-Type: application/json` and a signature header:

```
X-Selfwin-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with your secret>
```

```json
{
  "event": "article.published",
  "article": {
    "title": "Best AI Visibility Tools for Startups in 2026",
    "slug": "best-ai-visibility-tools-for-startups-2026",
    "description": "…",
    "markdown": "…",
    "html": "…",
    "tags": ["AI visibility"],
    "faq": [{ "q": "…", "a": "…" }],
    "publishedAt": "2026-10-28T09:00:00.000Z"
  }
}
```

Reject requests whose signature does not match or whose timestamp is more than five minutes old. Answer `{ "url": "https://yoursite.com/blog/<slug>" }` so Selfwin can link to the post and measure its citations. A `ping` event is sent when you test the destination; answer it with any 2xx.

## Lovable (Supabase)

Lovable sites keep their data in Supabase, so the simplest route is an edge function that stores each article in a `posts` table, and a blog page in your Lovable app that reads it.

1. In Lovable, ask: *"Add a blog that lists rows from a Supabase `posts` table (slug, title, description, content_html, published_at) at /blog and renders one post at /blog/:slug."*
2. Create the function `supabase/functions/selfwin-publish/index.ts`:

```ts
import { createClient } from "npm:@supabase/supabase-js@2";

const SECRET = Deno.env.get("SELFWIN_WEBHOOK_SECRET")!;
const SITE = "https://yoursite.com";

async function verify(header: string | null, body: string): Promise<boolean> {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${body}`)));
  const expected = [...mac].map((byte) => byte.toString(16).padStart(2, "0")).join("");
  let diff = expected.length ^ String(parts.v1 ?? "").length;
  for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ String(parts.v1).charCodeAt(i);
  return diff === 0;
}

Deno.serve(async (req) => {
  const body = await req.text();
  if (!(await verify(req.headers.get("x-selfwin-signature"), body))) return new Response("Unauthorized", { status: 401 });
  const event = JSON.parse(body);
  if (event.event === "ping") return Response.json({ ok: true });
  const { article } = event;
  const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
  const { error } = await supabase.from("posts").upsert(
    {
      slug: article.slug,
      title: article.title,
      description: article.description,
      content_html: article.html,
      published_at: article.publishedAt,
    },
    { onConflict: "slug" },
  );
  if (error) return Response.json({ error: error.message }, { status: 500 });
  return Response.json({ url: `${SITE}/blog/${article.slug}` });
});
```

3. Set the secret and deploy without JWT verification (Selfwin signs instead):

```
supabase secrets set SELFWIN_WEBHOOK_SECRET=<the same secret you give Selfwin>
supabase functions deploy selfwin-publish --no-verify-jwt
```

4. In Selfwin, add a **Webhook** destination with `https://<project-ref>.supabase.co/functions/v1/selfwin-publish` and the secret.

If your Lovable project syncs to GitHub and renders Markdown files, the **GitHub** destination works too.

## Next.js

A Markdown blog in your repo (`content/blog/*.md` or MDX): use the **GitHub** destination. Each article is committed with `title`, `description`, `date`, `slug`, and `tags` frontmatter, and Vercel or Netlify redeploys on the push.

A blog backed by a database or CMS: add a route handler and use the **Webhook** destination.

```ts
// app/api/selfwin/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { revalidatePath } from "next/cache";

function verify(secret: string, header: string | null, body: string) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest();
  const given = Buffer.from(String(parts.v1 ?? ""), "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

export async function POST(request: Request) {
  const body = await request.text();
  if (!verify(process.env.SELFWIN_WEBHOOK_SECRET!, request.headers.get("x-selfwin-signature"), body)) {
    return new Response("Unauthorized", { status: 401 });
  }
  const event = JSON.parse(body);
  if (event.event === "ping") return Response.json({ ok: true });
  const { article } = event;
  // Save article.slug, article.title, article.description, article.html to your CMS or database here.
  revalidatePath("/blog");
  return Response.json({ url: `https://yoursite.com/blog/${article.slug}` });
}
```

## Measuring results

Results appear on the Autopilot page after the next collection. "Since publishing" compares how many of the prompt's answers named you on the day the article was written with the latest measured day, shows your best position, and notes when the article itself was cited. Treat movement as correlation: many things change AI answers, and one article is rarely the only cause.

## Control each project

Open **All projects · automation** in the project switcher to see which projects are active. Each project has its own switch for the entire nightly loop: tracking, analysis, writing, and publishing. Pausing stops new work; an in-flight request may finish. Changing a blog connection does not resume a paused loop.

The default is **5 articles per night**, adjustable from **1 to 20**. This is a maximum: the agent writes only when eligible gaps exist.
