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.descriptionis optional. dependsOnlists earlier step numbers only, so there can be no cycles.dependsOnIssueslists existing issue ids.autoDispatchdefaults tofalse.
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
| What | How |
|---|---|
| Nothing is sent without a yes | The Cursor rule shows the plan and asks, every time. |
| Only approved work auto-starts | The sweep picks only auto_dispatch=1 issues. The old backlog is never swept. |
| Steps run in order | A 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 twice | The 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 submit | submit_plan needs the inbox secret, which is stored as a bcrypt hash. Cursor's key cannot read, change or delete rows. |
| No flood | At most 20 pending plans, at most 10 steps each, at most 1 MB each. |
| Spend | The daily cap, one run at a time, the review round cap: see cost guards. |
| Kill switch | MAINARK_COORDINATE_AUTO_DISPATCH=0. Plans are still imported, but nothing starts. |
Setting it up
In Supabase (Adrian):
- Create a project, or use an existing one. The inbox is a single table.
- In the SQL editor, run
docs/setup/plan-inbox.sql. Then set the secret, using the commented lines at the end, with a value fromopenssl rand -hex 32. - 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_inboxshows every submission with its status, the issue ids or the error.