Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/stacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ digits, spaces, underscores and hyphens after). Omitting it returns
curl -X POST https://api.instanode.dev/stacks/new \
-H "Authorization: Bearer <JWT>" \
-F "name=shop-stack" \
-F "manifest=@instant.yaml" \
-F "manifest=<instant.yaml" \
-F "api=@api.tar.gz" \
-F "web=@web.tar.gz"
```

> **`manifest` is a form VALUE, not a file upload.** Use `<` (read the file's contents into the
> field), not `@` (attach it as a file part). The handler reads `manifest` from the multipart
> *values*, so `-F "manifest=@instant.yaml"` lands in the file part and returns
> `400 missing_manifest`. The per-service tarball fields (`api=@…`, `web=@…`) DO use `@`.

```
services:
api:
Expand Down
76 changes: 73 additions & 3 deletions llms.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# instanode.dev

> Zero-friction developer infrastructure for AI agents. Provision real Postgres, Redis, MongoDB, NATS, S3-compatible object storage (DigitalOcean Spaces), webhooks, and container deploys via single HTTP calls — no signup, no API key, no Docker, no cloud account. The first 24 hours are anonymous; claim a resource to keep it past then ($9/mo).
> Zero-friction developer infrastructure for AI agents. Provision real Postgres, Redis, MongoDB, NATS, S3-compatible object storage (Cloudflare R2), webhooks, and container deploys via single HTTP calls — no signup, no API key, no Docker, no cloud account. The first 24 hours are anonymous; claim a resource to keep it past then ($9/mo).

This file follows the llms.txt convention (https://llmstxt.org). Every HTML route on `https://instanode.dev` has a parallel `.md` mirror at the same path with a `.md` suffix — e.g. `/use-cases/foo` is served as HTML and `/use-cases/foo.md` is the same content in plain markdown. The aggregated full text of every page is at [/llms-full.txt](https://instanode.dev/llms-full.txt) for one-shot consumption.

Expand All @@ -21,6 +21,8 @@ All accept `POST` against `https://api.instanode.dev`. No authentication header
Every provisioning endpoint — `/db/new`, `/vector/new`, `/cache/new`, `/nosql/new`, `/queue/new`, `/storage/new`, `/webhook/new`, `/deploy/new`, `/stacks/new` — **requires** a `name` on the request. It is the human-readable label shown in the dashboard and in `GET /api/v1/resources`. An empty JSON body is no longer accepted.

- JSON-body endpoints (`/db/new`, `/vector/new`, `/cache/new`, `/nosql/new`, `/queue/new`, `/storage/new`, `/webhook/new`, `/stacks/new`) take `name` as a JSON string field.

**Redis keys must carry the `key_prefix` from the provisioning response.** `/cache/new` returns `key_prefix` (`"<token>:"`); the tenant's ACL user is scoped to it, so a bare `SET mykey` returns the native Redis error `NOPERM No permissions to access a key` — not a platform JSON envelope, so there is no `agent_action` to recover from. Prefix every key: `SET <key_prefix>mykey`.
- `/deploy/new` is multipart — pass `name` as a form field (`-F "name=..."`).
- **Validation:** 1–64 characters, must match `^[A-Za-z0-9][A-Za-z0-9 _-]*$` — start with a letter or digit; remaining characters may be letters, digits, spaces, underscores, or hyphens.
- Omitting `name` returns `400 {"error":"name_required"}`.
Expand All @@ -33,7 +35,7 @@ Pick a descriptive name per resource (e.g. `"prod-db"`, `"sessions-cache"`, `"ev
- **`POST /cache/new`** — Redis. Requires `name`. Per-token ACL'd user + namespaced keyspace. Returns `connection_url` in the form `redis://:PASS@HOST:PORT/DB`.
- **`POST /nosql/new`** — MongoDB. Requires `name`. Per-token user scoped to a single database. Returns a `mongodb://...` connection URL. The per-token connection budget is documented in the response `limits.connections` field (e.g. anonymous = 2). The underlying shared-tenant pod admits up to 20 simultaneous connections across all tokens, so plan agents to stay well below their per-token allocation under burst.
- **`POST /queue/new`** — NATS JetStream. Requires `name`. Returns `connection_url` (`nats://host:4222`) plus a `credentials` object with per-tenant NATS account creds: `credentials.nats_jwt`, `credentials.nats_nkey`, and a pre-rendered `credentials.creds_file` blob. Pass `(nats_jwt, nats_nkey)` to `nats.UserJWTAndSeed()` or write `creds_file` to disk and use `nats.UserCredentials(path)`. Each tenant gets its own NATS account — JetStream streams, subjects, and pub/sub are isolated at the server. `subject_prefix` in the response names the subject namespace this resource is scoped to. The response also includes `auth_mode` ("isolated" or "legacy_open" for grandfathered pre-cutover rows). Durable streams, request/reply, pub/sub.
- **`POST /storage/new`** — S3-compatible bucket prefix backed by DigitalOcean Spaces (`nyc3`). Requires `name`. Returns `connection_url` (`https://s3.instanode.dev/instant-shared/<prefix>/`) plus `endpoint`, `prefix`, and a `mode` field that names the isolation level the tenant landed on. **Today, on DO Spaces, every new tenant (all tiers) lands in `broker` mode**: NO long-lived credential is returned — the response OMITS `access_key_id`/`secret_access_key` and instead carries `presign_url` + `agent_action:"use_presign_endpoint"`. Call `POST /storage/:token/presign` for short-lived (≤1h) signed S3 URLs scoped to your `prefix/*`. The other modes are not currently issued to new tenants: `shared-master-key` (legacy DO Spaces rows only — every tenant held the master key, prefix-by-convention), `prefix-scoped` (backend IAM enforces `s3:prefix` against `<prefix>/*` — R2/S3/MinIO target), `prefix-scoped-temporary` (same but credentials expire — STS). Anonymous-tier objects are auto-deleted at 24h by a bucket lifecycle rule. See [/use-cases/screenshot-evidence-archive.md](https://instanode.dev/use-cases/screenshot-evidence-archive.md) for a worked example.
- **`POST /storage/new`** — S3-compatible object storage backed by **Cloudflare R2**. Requires `name`. Returns `connection_url`, `endpoint`, `prefix`, and a `mode` field naming the isolation level the tenant landed on. **Today every new tenant (all tiers) lands in `broker` mode**: NO long-lived credential is returned — the response OMITS `access_key_id`/`secret_access_key` and carries `agent_action:"use_presign_endpoint"`. Call `POST /storage/:token/presign` with `{"key":"<path>","operation":"PUT"|"GET"|"HEAD"}` for a short-lived (≤1h) signed URL scoped to your `prefix/*`; `operation` is required and must be one of those three. The other modes are not currently issued: `shared-master-key` (legacy — every tenant held the master key), `prefix-scoped` and `prefix-scoped-temporary` (real per-tenant R2 tokens; target state, needs the account-id plumbing in the api). Anonymous-tier objects are deleted at expiry by the TTL reaper, which removes everything under the tenant prefix — **not** by a bucket lifecycle rule. There is deliberately no bucket-wide expiry: the tenant prefix carries no tier marker, so a bucket rule cannot tell an anonymous object from a paying customer's, and a previous whole-bucket rule silently deleted every backup within 24h.
- **`POST /webhook/new`** — Public receive URL that captures any HTTP method. Requires `name`. Returns `receive_url`. Inspect received payloads at `GET https://api.instanode.dev/api/v1/webhooks/{token}/requests`.
- **`POST /storage/{token}/presign`** — Mint a short-lived (≤1h) signed S3 URL for a storage resource that landed in `mode="broker"` (no long-lived credential issued by `/storage/new` — DO Spaces today for new tenants). Body: `{"operation": "PUT"|"GET", "key": "<object-key-relative-to-prefix>", "expires_in": <seconds, 1..3600>}`. Returns `{ok, url, expires_at}`. Signed by the platform master key but constrained to the resource's own `prefix/*`, so a leaked URL cannot escape the tenant boundary. Rate-limited per token. Don't use this when the `/storage/new` response carried `(access_key_id, secret_access_key)` — go direct to S3 in that case.
- **`POST /webhooks/brevo/:secret`** — Brevo delivery webhook receiver (internal — Brevo's transactional pipeline POSTs here for every delivery event). Authentication is by URL token: the `{secret}` path segment is constant-time-compared against the platform's `BREVO_WEBHOOK_SECRET`. Handled events: `delivered`, `soft_bounce`, `hard_bounce`, `blocked`, `complaint`, `deferred`, `unsubscribed`, `error`. The handler overwrites the matching `forwarder_sent` row's `classification` with the real Brevo outcome and stamps `delivered_at` on `delivered` only. Unknown messageIds return `200 {"matched":false}` (Brevo retries on 5xx — orphan events must not amplify retries). Unhandled event types (`click`, `open`, `request`) return `200 {"skipped":true}`. This is the truth surface for "did the user receive the email" — the worker's 201 from Brevo's API only means the relay queued the message; `forwarder_sent.classification` (set by this webhook) is the actual delivery outcome.
Expand Down Expand Up @@ -95,7 +97,9 @@ curl -X POST https://api.instanode.dev/claim \

**`token` is the canonical request field (since 2026-05-20).** The legacy `jwt` field is still accepted as a deprecated alias for backward compatibility with the dashboard, sdk-go, mcp, and existing curl recipes — when both are present, `token` wins. The OpenAPI spec marks `jwt` as `deprecated: true`.

The `email` field must parse as a valid RFC 5322 address (validated via Go `mail.ParseAddress` + 254-char RFC 5321 §4.5.3.1.3 cap + dotted-domain + no-inner-whitespace gates, since 2026-05-20). A non-email string returns `400 {"error":"invalid_email_format"}` — the claim no longer mints users with unreachable addresses. A magic link arrives by email. Clicking it sets a session cookie that owns every resource attached to your network fingerprint. Tier starts at Free (24h TTL, same limits as anonymous) — claiming gives you an account, not durability. Resources still expire at 24h until the team upgrades to a paid tier (Hobby $9/mo or above) in the dashboard.
The `email` field must parse as a valid RFC 5322 address (validated via Go `mail.ParseAddress` + 254-char RFC 5321 §4.5.3.1.3 cap + dotted-domain + no-inner-whitespace gates, since 2026-05-20). A non-email string returns `400 {"error":"invalid_email_format"}` — the claim no longer mints users with unreachable addresses.

**`POST /claim` returns a `session_token` in its 201 body, usable immediately as `Authorization: Bearer <session_token>` — no email round-trip.** This is the path an unattended agent should use. The field is `session_token` (not `session_jwt`, `jwt` or `api_token`). A magic link is also sent by email for humans who want a browser session. Clicking it sets a session cookie that owns every resource attached to your network fingerprint. Tier starts at Free (24h TTL, same limits as anonymous) — claiming gives you an account, not durability. Resources still expire at 24h until the team upgrades to a paid tier (Hobby $9/mo or above) in the dashboard.

## Tiers

Expand Down Expand Up @@ -289,6 +293,72 @@ Full guide: [https://instanode.dev/docs#troubleshooting-deploys](https://instano

Every error envelope includes an `agent_action` string when recovery is possible — surface it to the user verbatim. The `retry` field (seconds) signals the server-suggested back-off when present.

## Resource health-checks and live connectivity testing

After provisioning, an agent should verify a resource is reachable before writing application code that depends on it. Use these patterns:

### Checking provisioned resource status
- **`GET /api/v1/resources`** — list all resources for the authenticated team. Returns `{ok, resources: [{id, token, kind, name, tier, status, environment, created_at, expires_at}]}`. The `status` field is one of `active`, `expired`, `deleted`, or `error`. Filter by `?kind=postgres` to scope to a service type.
- **`GET /api/v1/resources/:token`** — get a single resource by its provision token. Returns the same shape as a single element from the list, plus `connection_url` (decrypted for the calling team) and `limits` (tier-specific maximums).
- **`status:"active"`** is the only signal that the resource is live and provisioned — `error` means the provisioner returned a failure during initial setup (rare; usually a transient backend issue; re-provision with a new name).

### Verifying connectivity without credentials in source code
When the agent's goal is to *confirm* a database is reachable before handing off to the user:

```bash
# Quick Postgres reachability check
PGPASSWORD="<password>" psql "<connection_url>" -c "SELECT 1;" 2>&1

# Quick Redis reachability check
redis-cli -u "<connection_url>" PING

# Quick MongoDB reachability check
mongosh "<connection_url>" --eval "db.runCommand({ping:1})" --quiet
```

The agent should only emit `connection_url` to the user after confirming `SELECT 1` returns `1` (or equivalent). A non-`active` status or a failed ping means re-provision — call the same endpoint again with a new `name`; the failed resource token is inert.

### TTL awareness
Every response includes `expires_at` (ISO 8601, null for permanent). An anonymous resource expires 24h after creation. The agent should surface `expires_at` to the user so they can claim the resource before the window closes. The claim URL is in the original provision response as `upgrade`.

## Rate limiting, quota exhaustion, and recovery patterns

InstaNode enforces two distinct throttle layers. Agents must handle both without user intervention.

### Anonymous fingerprint rate limit (pre-auth)
Unauthenticated requests are fingerprinted by IP subnet + ASN. The daily cap is **5 new provisions per fingerprint**. On the 6th attempt within 24 hours, the API returns the *same* token as the most recent same-kind provision for that fingerprint — not a 429 — so the agent receives a valid (if duplicate) resource rather than an error. If distinct resources are needed, the user must claim (authenticate) first.

### Quota exhaustion (authenticated, over-tier)
When a team's provisioned resource count reaches the tier limit, `POST /db/new` (or any provision endpoint) returns:

```json
{
"ok": false,
"error": "quota_exceeded",
"agent_action": "Upgrade to Pro at https://instanode.dev/pricing to provision more databases.",
"upgrade": "https://instanode.dev/pricing"
}
```

HTTP status is **402 Payment Required**. The agent should surface `agent_action` verbatim to the user and stop retrying — quota exhaustion is deterministic, not transient.

### Rate limiting on write endpoints (429)
Heavy-burst provisioning (>20 requests/minute to provisioning endpoints) triggers a 429 with a `Retry-After` header (seconds) and `{"error":"rate_limited","retry":<seconds>}` in the body. Back off by the `retry` value before retrying. Do not retry immediately — the server-side sliding window is per-IP and does not reset on connection close.

### Error shapes reference

| HTTP | `error` field | Transient? | Agent action |
|---|---|---|---|
| 402 | `quota_exceeded` | No | Surface `agent_action` + stop |
| 402 | `tier_required` | No | Surface upgrade link |
| 429 | `rate_limited` | Yes | Wait `retry` seconds |
| 503 | `provisioner_unavailable` | Yes | Retry with 5s back-off, max 3× |
| 409 | `name_taken` | No | Choose a different `name` |
| 400 | `name_required` | No | Add `name` field |
| 400 | `invalid_name` | No | Fix name: `^[A-Za-z0-9][A-Za-z0-9 _-]*$` |

The `agent_action` string is always present on 402 responses and is human-readable. Print it directly into any user-facing message.

## How to use this file

If you're an LLM helping a user build something, you can:
Expand Down
Loading