Plan inbox

Plan in Cursor, on desktop, phone or Slack. Mainark creates the issues and starts them.

Cursor agent ── "plan X" ──▶ shows the steps ──▶ user: "send it, auto-start"
      │
      └─ node cli/push-plan.mjs plan.json
            │  submit_plan(secret, plan)          Supabase: public.plan_inbox
            ▼
   coordinate tick (every 30s on the box)
      1. merged PRs → their issues `done`         (syncMergedIssues)
      2. pending inbox rows → validate → issues   (importPlanInbox)
         write back the ids, or why it was refused
      3. sweep: start opted-in issues whose dependencies are done
            │
            ▼
   implement → Claude review → fix → PR, comments on the PR, Slack
      human merges step 1 → the next tick marks it done → step 2 starts

Why an inbox in Supabase

Cursor's cloud agents (the ones started from the phone, web or Slack) cannot reach Mainark. It listens only on Tailscale, and it should not be public. Supabase is reachable from anywhere. It is only a mailbox: SQLite on the box stays the ledger, and nothing else in Mainark reads Supabase.

When a Cursor agent can reach Mainark directly (on the box, or over Tailscale), push-plan.mjs can use POST /api/issues/plan instead. It uses the same format and the same checks.

The plan format

It's defined and validated in one place, src/tasks/plannedIssues.ts:

  • Up to 10 steps, each with a title. description is optional.
  • dependsOn lists earlier step numbers only, so there can be no cycles.
  • dependsOnIssues lists existing issue ids.
  • autoDispatch defaults to false.

A plan is taken whole or refused whole, with the reason. Each issue's text says which step of which plan it is, and "Do only this step".

Guards

WhatHow
Nothing is sent without a yesThe Cursor rule shows the plan and asks, every time.
Only approved work auto-startsThe sweep picks only auto_dispatch=1 issues. The old backlog is never swept.
Steps run in orderA step starts only when its dependencies are done, which happens when the PR is merged. A PR closed without merging blocks the steps after it.
Never imported twiceThe import and its inbox_imports record are written in one transaction. If writing back to Supabase fails, the next tick only retries the write-back.
Nobody else can submitsubmit_plan needs the inbox secret, which is stored as a bcrypt hash. Cursor's key cannot read, change or delete rows.
No floodAt most 20 pending plans, at most 10 steps each, at most 1 MB each.
SpendThe daily cap, one run at a time, the review round cap: see cost guards.
Kill switchMAINARK_COORDINATE_AUTO_DISPATCH=0. Plans are still imported, but nothing starts.

Setting it up

In Supabase (Adrian):

  1. Create a project, or use an existing one. The inbox is a single table.
  2. In the SQL editor, run docs/setup/plan-inbox.sql. Then set the secret, using the commented lines at the end, with a value from openssl rand -hex 32.
  3. Hand over the project URL and the secret (service role) key. These go on the box only.

On the box (/etc/mainark/mainark.env, then restart mainark-coordinate):

MAINARK_INBOX_SUPABASE_URL=https://<project>.supabase.co
MAINARK_INBOX_SUPABASE_SERVICE_KEY=<secret key>
MAINARK_COORDINATE_AUTO_DISPATCH=1        # only when ready to let plans start

In Cursor (the environment's secrets for cloud agents, or the shell for desktop):

MAINARK_INBOX_URL=https://<project>.supabase.co
MAINARK_INBOX_ANON_KEY=<publishable / anon key>
MAINARK_INBOX_SECRET=<the inbox secret>

The rule .cursor/rules/mainark-planning.mdc loads by itself when you ask Cursor to plan something.

Checking on it

  • Box log (journalctl -u mainark-coordinate):
    • plan inbox row N → MAI-… when a plan is imported;
    • … rejected: <why> when one is refused;
    • MAI-… done (PR #N merged) when a step's PR is merged.
  • In Supabase, plan_inbox shows every submission with its status, the issue ids or the error.