---
service: nodestr.cloud
role: customer-agent
version: 0.2.0
openapi: /openapi.yaml
api_reference: /api.html
auth: NIP-98
---

# nodestr customer agent skill

You are an **autonomous customer agent** using [nodestr.cloud](https://nodestr.cloud) to order and manage prepaid VPS instances. Pay in sats (Lightning), receive a VM, connect via SSH.

**Fetch on boot:** `GET /agent-skill.md` · `GET /openapi.yaml` · `GET /api.html`

## Identity & auth

- **Customer identity** = Nostr pubkey. Prove it with **NIP-98** on every authenticated request.
- **Never** send nsec / hex private keys to nodestr. Sign locally.
- Optional **autopay**: NIP-98 tag `["debit", "ndebit1…"]` on pay/topup requests (not on create).
- Optional **API key**: `POST /v1/auth/api-key` (NIP-98 once) → `X-Api-Key: ndk_…`. API keys **cannot** attach ndebit; use NIP-98 for autopay.

### NIP-98 signing

1. Event `kind=27235`, tags `[["u", "<exact-request-url>"], ["method", "GET|POST|PUT|PATCH"]]`, `content=""`
2. Sign with customer private key
3. Header: `Authorization: Nostr <base64(JSON signed event)>` (standard base64 or base64url)

**URL must match exactly** what the server sees: scheme, host, port (if non-default), path, and query string. Event age limit: **60 seconds**. Method tag is compared uppercase.

## Base URL

| Environment | URL |
|-------------|-----|
| Production | `https://nodestr.cloud` |
| Local dev | `http://localhost:3000` |

## Response envelope

```json
{ "data": { } }
{ "error": { "code": "invalid_hostname", "message": "…" } }
```

On errors, surface `error.message`. On failed orders, read `user_error` and `progress`.

## Public endpoints (no auth)

| Method | Path | Purpose |
|--------|------|---------|
| GET | /v1/health | Liveness |
| GET | /v1/config | `markup_percent`, `payments_enabled`, `vapid_public_key` |
| GET | /v1/plans | Catalog; plans in `data.lines` |
| GET | /v1/rates | `btc_usd` spot |
| POST | /v1/orders/quote | Live quote → `retail_usd`, `amount_sats` |

## Authenticated endpoints

### Orders

| Method | Path | Purpose |
|--------|------|---------|
| POST | /v1/orders | Create order → status `pending_payment` (**no** invoice yet) |
| POST | /v1/orders/:id/pay | Request payment → bolt11 **or** ndebit pull |
| POST | /v1/orders/:id/cancel | Cancel unpaid order only |
| GET | /v1/orders | List orders |
| GET | /v1/orders/:id | Status, `progress`, `vm`, `payment` |

### VMs

| Method | Path | Purpose |
|--------|------|---------|
| GET | /v1/vms | List VMs (+ `manage` context, `ssh_hint`) |
| GET | /v1/vms/:id | VM detail |
| GET | /v1/vms/:id/credentials | Root password when no SSH key (`password_available`) |
| POST | /v1/vms/:id/upgrade/preview | Price a resource change |
| PATCH | /v1/vms/:id/upgrade | Apply resource change (wallet debit) |
| PATCH | /v1/vms/:id/start | Power on |
| PATCH | /v1/vms/:id/stop | Power off |
| PATCH | /v1/vms/:id/restart | Restart |
| PATCH | /v1/vms/:id/reinstall | Reinstall OS (`{ "template": "…" }`) |
| POST | /v1/vms/:id/ssh-key | Add SSH public key live |
| POST | /v1/vms/:id/cancel | Destroy VM / stop billing |

### Account

| Method | Path | Purpose |
|--------|------|---------|
| GET | /v1/customers/me | Profile + `balance_usd` |
| PUT | /v1/customers/me/autopay | Save `{ "ndebit": "ndebit1…" }` or `null` |
| POST | /v1/customers/me/topup | Wallet topup → `{ paid: true }` or bolt11 |
| POST | /v1/auth/api-key | Mint API key (shown once) |

## Order / quote body

```json
{
  "plan_id": "<uuid from GET /v1/plans>",
  "pricing_id": 123,
  "hostname": "mybox.example.net",
  "ssh_key": "ssh-ed25519 AAAA…",
  "config_options": {
    "42": "32768",
    "43": "8",
    "44": "64",
    "45": "debian13-cloud",
    "46": "none"
  }
}
```

- `plan_id` **or** `package_id` (integer) — required; pick from live catalog (never hardcode SKUs)
- `pricing_id` — from plan `pricing[]`; omit to use plan day rate when available
- `hostname` — required; lowercase FQDN-ish hostname
- `ssh_key` — **recommended**; passwordless SSH
- `config_options` — keys are **option_id strings** (not names like `"ram"`). Values from that option's `values[].value`.

**How to build `config_options`:**

1. `GET /v1/plans` → pick plan → find `config_options` row matching your `pricing_id` / period
2. Each option has `option_id`, `name`, and `values[]` with `default: true` on one value
3. Send only upgrades from defaults (or full set); keys must be `String(option_id)`

Desktop (`gui_choice` ≠ `none`) needs ≥16 GB RAM and disk on a supported Linux template.

## Recommended workflow: provision a VM

```
1. GET  /v1/config                       # check payments_enabled
2. GET  /v1/plans                        # pick plan_id + pricing_id + option_ids
3. POST /v1/orders/quote                 # confirm retail_usd / amount_sats
4. POST /v1/orders                       # → pending_payment (no bolt11)
5. POST /v1/orders/:id/pay               # NIP-98; optional debit tag for ndebit
       If data.payment.bolt11 → pay Lightning (watch payment.expires_at)
       Else status moved past pending_payment → continue
6. Poll GET /v1/orders/:id every 2–3s
       until status == "ready" OR "failed" OR "canceled"
7. On ready:
       vm.ssh_hint   → e.g. "ssh debian@203.0.113.10"
       If password_available → GET /v1/vms/:id/credentials
8. SSH into the VM (not into nodestr)
```

Cancel unpaid: `POST /v1/orders/:id/cancel` while `pending_payment`.

## Order status ladder

```
pending_payment → confirmed → provisioning → ready
```

Terminal: `failed` (read `user_error`) · `canceled`

## Wallet & topup

1. `GET /v1/customers/me` — `balance_usd`
2. `POST /v1/customers/me/topup` `{ "amount_usd": "10.00" }`
   - `{ "paid": true, "balance_usd": "…" }` — ndebit pull succeeded
   - `{ "paid": false, "bolt11": "…", "expires_at": "…" }` — pay invoice, then poll `GET /v1/customers/me`
3. `PUT /v1/customers/me/autopay` `{ "ndebit": "ndebit1…" }` — persist pointer for renewals

## VM manage

`GET /v1/vms` / `:id` include a `manage` object when actionable:

- `manage.pricing_ref` — pass as `pricing_ref` on upgrade
- `manage.resource_options` — RAM/CPU/disk choices (`option_id` + values + prices)
- `manage.current_config` — current option_id → value map
- `manage.templates` — reinstall OS list (`value` / `label`)
- `manageable` — whether power/manage actions are allowed
- `runtime_status` — running vs stopped (prefer over guessing)

**Upgrade:**

1. `POST /v1/vms/:id/upgrade/preview` `{ "pricing_ref", "config_options": { "<option_id>": "<new>" } }`
2. If `shortfall_usd` / `402 insufficient_balance` → topup first (`error` extras: `balance_usd`, `amount_due_usd`, `shortfall_usd`)
3. `PATCH /v1/vms/:id/upgrade` with same body

**Credentials:** only when `password_available` and no SSH key. May return `password_unavailable` until ready — retry.

## SSH

- Prefer `ssh_key` at order time.
- After ready: `vm.ssh_hint`, or `POST /v1/vms/:id/ssh-key` to add a key later.
- nodestr itself has no SSH — HTTP API only.

## What you cannot do via API (v1)

- SSH into nodestr
- Auto-refund on failed provision (operator/admin)
- Sanctum SDK (use NIP-98 or API key)
- Explicit renew endpoint (prepaid auto-renew from wallet/ndebit when configured)

## Browser-only (ignore for CLI agents)

- Web Push (`/v1/push/subscribe`)
- Catalog UI at `/`

## Checklist before first paid order

- [ ] `GET /v1/health` OK
- [ ] `GET /v1/config` → `payments_enabled: true`
- [ ] NIP-98 works (`GET /v1/customers/me`)
- [ ] Live plan from `GET /v1/plans` (do not hardcode package IDs)
- [ ] Lightning wallet or ndebit ready for `POST …/pay`

---

*nodestr — cloud VPS, paid in sats. Powered by SHOCKNET.*
