# Plinth for agents

Plinth hosts an app end to end: git repo, builds, deploys, Postgres per environment, migrations, cron, domains, logs. You drive it. Your human owner approves risky steps.

API: https://tryplinth.dev

## Install

```sh
curl -fsSL https://tryplinth.dev/_plinth/install.sh | sh        # CLI at ~/.local/bin/plinth
```

MCP config (Claude Code, Codex, Cursor, any MCP client):

```json
{ "mcpServers": { "plinth": { "command": "npx", "args": ["-y", "https://tryplinth.dev/_plinth/cli.tgz", "mcp"],
  "env": { "PLINTH_URL": "https://tryplinth.dev" } } } }
```

If you can't run the install (no shell, no Node, downloads blocked, or `npx` fails), don't stop: use the remote MCP server instead. It needs nothing installed.

Remote MCP (cloud agents with no shell, e.g. Claude or ChatGPT connectors): `https://tryplinth.dev/mcp`.
Add it as a connector with just that URL: it signs in with OAuth. A browser tab opens, your owner enters their
email and approves the link Plinth sends; that creates the account (or adds you to theirs). Clients without
OAuth can use an existing token: `Authorization: Bearer <token>` or `https://tryplinth.dev/mcp/<token>`. Write code with `code.write`
(no git needed), read it back with `code.list` / `code.read`.

## 1. Get an account (owner approves once)

```sh
plinth signup --email owner@example.com --agent "Claude Code" --project my-app
```

Plinth emails your human a link to approve (it never goes to you): tell them to check their inbox. Approving also confirms the email is theirs. If that email already has a Plinth account, you join it as another agent with your own key, sharing its projects and rules. The command waits, then saves your token to `~/.plinth/config.json`.
Raw HTTP: `POST /v1/signup {"email","agent","project"}` → `email_sent_to`, `poll_url`. Poll until it returns `token` (once).

## 2. Ship

```sh
plinth link my-app          # adds git remote "plinth" with credentials
git add -A && git commit -m "first"
plinth push main            # main deploys to staging; waits and prints the URL
plinth push feat/x          # any other branch gets its own preview env + database copy
plinth promote              # staging -> production; owner approves
```

## What Plinth runs

A release is built from your commit:

- `package.json` dependencies are installed; a `build` script runs if present.
- Static files: first of `dist/`, `build/`, `out/`, `public/`, `./` that has `index.html`.
- Functions: `api/**/*.js` (also `.mjs`, `.ts`). `api/users/[id].js` serves `/api/users/:id`.
  Export `GET`/`POST`/… or `default`: `(request, { params, sql, env }) => Response | object`.
  `sql(text, params)` queries this environment's Postgres. `process.env.DATABASE_URL` is set too.
- Or a server: `"start"` in `plinth.json` (or `npm start` when there is no static/api). Listen on `process.env.PORT`.
- Migrations: `migrations/*.sql`, applied in name order on every deploy and promote, each in a transaction, after an automatic snapshot.
  Destructive SQL (DROP, TRUNCATE, unscoped DELETE/UPDATE, type changes) reaching production waits for the owner.

Single-page apps work out of the box: unknown paths that ask for HTML get `index.html`. Set `"spa": false` to serve real 404s.

Every build and every running app is its own gVisor sandbox: its own kernel, read-only code at `/app`,
writable `/tmp` only, 256 MB RAM, half a CPU, its own network. Apps can reach the internet and their own
database, nothing else. Write files to the database or an external store, not to disk.

Optional `plinth.json`:

```json
{
  "build": "npm run build",
  "static": "dist",
  "spa": true,
  "banner": true,
  "functions": "api",
  "crons": [{ "name": "digest", "schedule": "0 9 * * 1", "path": "/api/cron/digest" }]
}
```

Cron calls `POST <path>` on that environment (UTC, 5 fields) with the header `x-plinth-cron-secret`
(also in `process.env.PLINTH_CRON_SECRET`). Put handlers under `/api/cron/`: Plinth refuses every other caller there.
Elsewhere, check `ctx.cron` in your function. A failed run is retried once after 30s; overlapping runs are skipped.
Production jobs run by default. Staging and preview jobs start paused: pass `"enabled": true` or call `cron.resume`.

## Environments

| env | tracks | database | url |
|---|---|---|---|
| preview | its branch | copy of staging at creation | `https://<branch>--<project>.<apps domain>/` |
| staging | main | own, nightly snapshot | `https://staging--<project>.<apps domain>/` |
| production | promote only | own, nightly snapshot, 7 kept | `https://<project>.<apps domain>/` or a custom domain |

Staging and preview pages show a bar at the top: "this isn't the live site", with a button that copies a prompt for the owner to paste to you (publish staging, or merge a preview branch). It helps non-technical owners. Production never shows it. Set `"banner": false` in `plinth.json` to hide it.

Free projects pause after 7 days without use (real visitors, deploys, your actions on the project, or the owner opening the console). Plinth emails the owner and tells you, in `notices`, 2 days ahead and again when it pauses. Pausing backs up each database and stops the app; code, versions, logs and analytics are kept at least 30 days. The next real visit wakes it in a few seconds (bots get a 503 and don't wake it), and so does a deploy or `env.restart`. Scheduled jobs wait while paused. Pro projects never pause.

Each environment has its own hostname, so apps run at `/` exactly as in local dev. `env.list` returns the real URLs.
Secrets you set with `env.secrets.set` are encrypted at rest and never returned.

## Environment variables

```sh
plinth env.secrets.set --env staging --key STRIPE_SECRET --value sk_test_...
plinth env.secrets.import --env staging --file .env      # many at once, one restart (--replace true removes others)
plinth env.secrets.list --env staging                    # names, when and by whom; values are never shown
plinth env.secrets.unset --env staging --key OLD_KEY
```

Each environment has its own variables, encrypted at rest; setting one restarts that environment. New preview environments start with a copy of staging's. Variables starting with `VITE_`, `NEXT_PUBLIC_`, `PUBLIC_`, `REACT_APP_`, `NUXT_PUBLIC_`, `EXPO_PUBLIC_` or `GATSBY_` are also passed to the build, because frontend tools bake them into the browser bundle: never put secrets in those. Plinth sets `DATABASE_URL`, `PORT`, `NODE_ENV`, `PLINTH_PROJECT`, `PLINTH_ENV` and `PLINTH_CRON_SECRET` for you.

## Debugging

```sh
plinth env.status --env staging             # running? restarts, out-of-memory kills, memory/CPU, last errors, hints
plinth logs.query --env staging --since 1h --stream stderr
plinth requests.query --env staging --min_status 500    # failing requests
plinth http.probe --env staging --path /api/orders     # reproduce one request, see status, headers, body
plinth env.exec --env staging --command "node -e 'console.log(process.env.DATABASE_URL ? 1 : 0)'"
plinth env.restart --env staging
plinth deploy.status --env staging          # releases and the latest build log
```

## Analytics

`plinth analytics.get --env production` returns visitors and page views per day, top pages, referrers and devices. No cookies are used. Free shows today; Pro keeps 90 days.

## SEO

```sh
plinth seo.setup --project my-app        # production by default
```

Checks what search engines see (title, description, h1, robots.txt, sitemap.xml, noindex) and returns what to fix, plus the steps to connect [OpenSEO](https://openseo.so): an open-source SEO tool you use over MCP (`https://app.openseo.so/mcp`) for keyword research, rank tracking, backlinks and site audits. The owner signs up there; Plinth shares no data with it. Staging and previews send `X-Robots-Tag: noindex`, so only production is indexed.

## Support

Stuck, found a bug, or need a feature? Tell the Plinth team directly:

```sh
plinth support.send --kind bug --title "Short summary" --body "Steps, expected, actual, error text" --project my-app --env staging
plinth support.list          # status and the team's reply
```

Recent errors from the environment are attached automatically. When the team replies, the reply is attached (once) to the result of your next Plinth command or MCP tool call, as `notices`; act on it and tell your human if it matters.

## Webhooks

```sh
plinth webhooks.add --url https://hooks.slack.com/services/... --events '["deploy.failed","approval.requested"]'
plinth webhooks.test
plinth webhooks.list
```

Events: deploy.succeeded, deploy.failed, approval.requested, approval.decided, app.down, app.up, support.created, support.replied, charge.succeeded. JSON body `{event, ts, data, text}`, signed with `x-plinth-signature: sha256=<hex HMAC of the raw body>`. Slack and Discord incoming-webhook URLs and Telegram `sendMessage?chat_id=` URLs work directly.

## Domains

```sh
plinth domains.search --query acme                  # acme.com, .dev, .app, .io ... with prices
plinth domains.buy --env production --domain acme.dev   # owner approves the exact price
plinth domains.connect --env production --host app.acme.co   # a domain you already own
```

`domains.connect` returns two DNS records: a TXT record `_plinth-verify.<host>` that proves the owner controls the domain, and a CNAME to Plinth. The domain is served only once verified: call `domains.verify` after the records are in place. Plinth's own hostnames can't be connected.

`domains.buy` registers the domain, points it at Plinth and serves it over HTTPS (apex and www). Only standard registrations are sold, no premium names. If the price changes before approval, the purchase is refused and you ask again.

## Payments

Purchases (like `domains.buy`) charge the owner's saved card after they approve the exact price.

```sh
plinth billing.status        # card on file? spend this month, cap, recent charges
plinth billing.card          # no card: give the owner this link; they add it on Stripe
plinth project.upgrade --project my-app     # Pro, $19/month per project; owner approves.
                                            # Pro: own domains, 1 GB / 1 CPU, 30-day backups, 90-day analytics,
                                            # a preview per branch, uptime emails, never pauses
plinth project.downgrade --project my-app   # back to Free
```

Custom domains (`domains.buy`, `domains.connect`) need the project on Pro. Charges and subscription changes are prorated and billed to the owner's card.

If the card is declined, nothing is bought. If a purchase fails after the charge, it is refunded automatically.

## Approvals

These return `{"status":"pending_approval","approve_url":…}` until the owner approves:
project.create, project.delete, promote to production, any write to production data (`db.query` writes, migrations that delete or rewrite data), `db.connection` and `env.exec` on production, db.restore on production, domains.buy.
Reads on production (`db.query` with SELECT, WITH, SHOW or EXPLAIN) run without approval, in a read-only transaction.
Plinth emails the owner a link to approve each request; only that link, or the owner signed in to the console, can approve. `approve_url` shows the request: the CLI and MCP server open it in your human's browser (the result says `opened_in_browser: true`), and they can approve there if signed in. Tell them it's waiting, then poll `approval.status` (the CLI waits for you). A promote approval covers the exact release shown; if staging changes first, it fails and you ask again. If your human asked you not to open things, pass `--no-open` (CLI) or `no_open: true` (MCP), or run `plinth config open_approvals false` once. No desktop (CI, SSH, headless Linux) means nothing opens: send them the link instead. The result of the action is in the approval.

If the owner connected Telegram, WhatsApp or SMS in the console, each request also reaches their phone with Approve and Deny (SMS: reply `YES 123456`), plus a Review link to the full card. Requests that need a browser (console sign-in, adding a card, subscribing) offer only the link.

## Sharing a project

```sh
plinth project.share --project my-app --email friend@example.com   # owner approves; Plinth emails them an invitation
plinth project.members --project my-app                           # owner, members, invitations not yet accepted
plinth project.unshare --project my-app --email friend@example.com # remove them (or pass your own email to leave)
```

Once they accept (or sign up to Plinth with that email), the project shows up in their agents' `project.list` with `shared_by`. They work from their own account, with their own approvals: code, deploys, going live, databases, logs. Only the owner can delete it, change its plan or domains, or share it further (`project_owner_only`). Both sides get a note in `notices` when someone joins or leaves.

## Messaging the owner

`plinth owner.notify --message "Your site is live: https://…"` sends a short note to the owner's phone (Telegram, WhatsApp or SMS, whichever they connected), or by email if they connected none. Use it for things they should know now: something is live, something broke, you are blocked on them. Keep it to one to three plain sentences; up to 30 a day. Plinth also messages them by itself when a deploy fails or an app goes down.

When the owner wants to see their projects in the browser, run `plinth console` (or the `console.signin` tool): Plinth emails them a sign-in link. Approving it signs that browser into the console for 12 hours; the link expires after 15 minutes.

## All actions

`GET https://tryplinth.dev/v1/tools` returns every action with its JSON schema. Call one with
`POST /v1/actions/<name>` and `Authorization: Bearer <token>`, or `plinth <name> --key value`.
Every response is JSON: `{"ok":true,"result":…}` or `{"ok":false,"error":"code","message":"what to do"}`.
