Files
keysat/KEYSAT_AGENT_GUIDE.md
T

391 lines
14 KiB
Markdown

# Keysat Agent Integration Guide
How to build agents, bots, and automation that operate a Keysat instance.
Keysat was designed from the start to be agent-friendly. The admin API uses
plain HTTP + JSON with Bearer-token auth. There's an OpenAPI 3.1 spec for
discovery. Scoped API keys let you give an agent least-privilege access
without handing over the master credential. Errors carry stable machine-readable codes. Webhooks let an agent react to events instead of polling.
This guide is for the *operator side* of Keysat — running, configuring, and
performing day-to-day operations on a Keysat instance. For the *buyer side*
(validating licenses inside your app), see [KEYSAT_INTEGRATION.md](KEYSAT_INTEGRATION.md).
---
## Quick start
```bash
# 1. Discover the API surface
curl https://your-keysat-host/v1/openapi.json
# 2. Generate a scoped API key (in admin UI: Settings → API keys, or via curl)
curl -X POST https://your-keysat-host/v1/admin/api-keys \
-H "Authorization: Bearer $MASTER_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"label":"Support bot","role":"support"}'
# Response includes `token: ks_...`. Save it — it's only shown once.
# 3. Use the scoped key
curl https://your-keysat-host/v1/admin/licenses?status=active \
-H "Authorization: Bearer ks_..."
```
---
## Authentication
All admin endpoints use HTTP Bearer auth:
```
Authorization: Bearer <token>
```
Two kinds of tokens are accepted:
**Master admin API key** — the env-configured `KEYSAT_ADMIN_API_KEY` (visible
in StartOS Actions → Show credentials on first install). Full access to every
endpoint. This is the operator's credential. Don't hand it to agents.
**Scoped API keys** — additional tokens generated in admin UI → Settings →
API keys. Each carries a role that bounds what it can do. Format: `ks_<43 chars>`. Operators can revoke any scoped key from the same UI; revoked tokens stop working immediately.
### Role to scope mapping
| Role | What it can do |
|---|---|
| `read-only` | List / get every resource. Mutate nothing. |
| `license-issuer` | All `read-only` scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes. |
| `support` | All `license-issuer` scopes + cancel subscriptions + force-deactivate machines. |
| `merchant-onboard` | All `read-only` scopes + `products:write` + `policies:write` + `licenses:write` — the least-privilege credential for standing up a fresh catalog (create products, define policies/tiers, issue licenses against them) via the API. Deliberately excludes the support writes (subscriptions / machines) and every master-only gate. Tier caps still bound it. |
| `full-admin` | Every scope. Equivalent to the master key for most endpoints. |
Endpoints that touch settings (operator name, payment provider connections,
self-license activation, scoped API key management) always require the master
admin key. A `full-admin` scoped key cannot, for example, generate another
scoped key — that's a self-defeating elevation path.
---
## Discovering the API
Two complementary discovery mechanisms.
### OpenAPI 3.1 spec
`GET /v1/openapi.json` — unauthenticated. Returns a curated spec covering the
agent-relevant subset of endpoints. Use this with:
- **OpenAI Custom GPTs**: paste the URL as an Action.
- **OpenAI Assistants / Functions**: feed the spec to tool definition generators.
- **Claude tool use**: use the spec to derive your `tools` array; Claude Code agents can `WebFetch` the spec at runtime and reason about endpoints.
- **LangChain / AutoGen / Smolagents**: use their OpenAPI loaders.
- **Code generation**: `openapi-generator-cli generate -i /v1/openapi.json -g python -o ./client`.
The spec is a stable agent surface, not auto-derived from handler signatures.
We commit to keeping documented endpoints and field shapes stable across
minor releases.
### Embedded endpoint listing
This guide's "Common workflows" section below covers the most common agent
tasks with copy-paste examples.
---
## Response envelope conventions
Every error response uses the same JSON envelope:
```json
{
"ok": false,
"error": "tier_cap",
"message": "Your Creator tier allows up to 5 products. You're at 5...",
"upgrade_url": "https://licensing.keysat.xyz/buy/keysat?policy=pro"
}
```
`error` is a stable machine-readable code; `message` is human-readable. The
`upgrade_url` field appears on 402 (tier cap) responses so a UI can render an
upgrade CTA without parsing message strings.
### Error codes
| HTTP | `error` code | When |
|---|---|---|
| 400 | `bad_request` | Malformed body, missing required field, invalid enum value |
| 401 | `unauthorized` | No `Authorization: Bearer` header |
| 403 | `forbidden` | Wrong token, revoked scoped key, role doesn't grant required scope |
| 404 | `not_found` | Resource id doesn't exist |
| 409 | `conflict` | Slug collision, delete-with-references blocked, etc. |
| 402 | `tier_cap` | Operator's self-tier doesn't include the required entitlement |
| 429 | `rate_limited` | Rate limit hit (e.g. /v1/recover, /v1/validate) |
| 502 | `upstream_error` | BTCPay / Zaprite call failed |
| 503 | `service_unavailable` / `btcpay_not_configured` | Provider not yet connected |
| 500 | `internal_error` | Bug. Includes a trace id in logs; report it. |
### Validate response
`POST /v1/validate` is the one endpoint that returns 200 in all cases. Inspect
`ok` + `reason`:
| `reason` | Meaning |
|---|---|
| `bad_signature` | Signature doesn't verify against the trust-root pubkey |
| `not_found` | License key not in the daemon's DB |
| `revoked` | Operator revoked it |
| `suspended` | Operator suspended it (reversible) |
| `expired` | Past `expires_at` |
| `fingerprint_mismatch` | Different machine than the one bound on first activate |
| `product_mismatch` | License is for a different product than the caller asserted |
| `too_many_machines` | Activating this fingerprint would exceed `max_machines` |
---
## Common workflows
### Issue a comp license
```bash
curl -X POST $KS/v1/admin/licenses \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{
"product_slug": "recap",
"policy_slug": "pro",
"buyer_email": "alice@example.com",
"buyer_note": "Conference speaker comp"
}'
```
Returns the issued license object including `license_key`. The buyer pastes
the key into their app; subsequent validate calls return `ok: true` with the
policy's entitlements.
Scope required: `licenses:write` (any role except `read-only`).
### Revoke a license
```bash
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{"reason":"customer request"}'
```
The next online validate from the buyer's app returns `reason: revoked`. Not
idempotent — a second revoke of the same license returns `404 not_found` (treat
as success-equivalent on retry; see Idempotency below).
Scope required: `licenses:write`.
### Find a license by email
```bash
curl "$KS/v1/admin/licenses?buyer_email=alice@example.com" \
-H "Authorization: Bearer ks_..."
```
Returns matching licenses (without the `license_key` field — that's only
returned on issue / recover). Use the `id` for follow-up operations.
Scope required: `licenses:read`.
### Cancel a buyer's subscription
```bash
# Look up the subscription id first (filter by license_id if you have it)
curl "$KS/v1/admin/subscriptions?status=active" \
-H "Authorization: Bearer ks_..."
# Then cancel
curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \
-H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer requested"}'
```
License stays valid through the current cycle's `expires_at`. Renewal worker
stops issuing new invoices.
Scope required: `subscriptions:write`.
### Free a machine seat
```bash
curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
-H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer moved devices"}'
```
The seat opens up. The buyer's next validate from any machine takes the
freed seat.
Scope required: `machines:write`.
### Programmatic tier change (comp upgrade)
```bash
curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
-H "Authorization: Bearer ks_..." \
-d '{
"target_policy_slug": "pro",
"reason": "support resolution"
}'
```
Always applies as comp (no invoice) from the admin path. Buyer-initiated
paid upgrades go through `/v1/upgrade` (different endpoint, signed-license auth).
Scope required: `licenses:write`.
---
## Webhooks — react to events instead of polling
Configure webhook endpoints in admin UI → Webhooks. The daemon POSTs JSON
payloads, HMAC-SHA256 signed with the endpoint's secret, on these events:
| Event | Fires on |
|---|---|
| `license.issued` | New license minted (purchase, comp, redeem) |
| `license.revoked` / `license.suspended` / `license.unsuspended` | Admin operations |
| `license.tier_changed` | Tier upgrade/downgrade applied |
| `invoice.paid` | A BTCPay/Zaprite invoice settled |
| `subscription.renewal_pending` | Renewal worker created a fresh invoice |
| `subscription.renewal_skipped` | Renewal skipped (e.g. policy archived) |
| `subscription.cancelled` | Buyer or admin cancelled |
| `subscription.lapsed` | Past_due grace expired |
| `machine.activated` | First validate from a new fingerprint |
Verify signatures:
```python
import hmac, hashlib
def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
```
The header is `X-Keysat-Signature`. Failed deliveries retry with exponential
backoff up to 10 attempts; permanently-failed deliveries land in the DLQ
visible at admin UI → Webhooks → Failed.
---
## Designing a robust agent
A few patterns that work well in practice.
### Idempotency
The daemon's mutation endpoints are idempotent where they can be. Suspend,
unsuspend, archive, unarchive, subscription cancel — all return success on the
second call without changing state. Your agent can safely retry on network
errors.
One exception: **revoke is not idempotent** — revoking an already-revoked
license returns `404 not_found` (the row no longer matches the
`status != 'revoked'` update guard). When retrying a revoke after an ambiguous
network failure, treat a `404` as success-equivalent: the license is already
revoked.
### Pagination
List endpoints return up to ~100 rows by default. Use `?limit=N` and
`?offset=N` for larger result sets. The OpenAPI spec documents the limits
per endpoint.
### Rate limits
The admin endpoints have no per-IP rate limit today — operators are trusted.
The public endpoints (`/v1/validate`, `/v1/recover`) are rate-limited per
client IP (10/min for /recover; /validate is unlimited but a reasonable
agent calls it once per app boot + once per hour).
### Master key handling
If your automation needs `full-admin` because it touches operator-only
operations (creating other API keys, changing payment providers), use the
master key from a secret manager. If it can stay within license / product /
policy operations, **always use a scoped key**. Operators can revoke a
compromised scoped key without rotating the master credential.
### Backoff on 5xx
`internal_error` (500) is a bug or a transient DB lock. Retry with exponential
backoff (1s, 2s, 4s, 8s, give up). Don't retry on 4xx — those are deterministic
client errors.
---
## Concrete agent recipe — "Comp a license to anyone who emails support@"
```python
import os, requests, imaplib, email
KS = os.environ["KEYSAT_URL"]
TOKEN = os.environ["KEYSAT_API_KEY"] # license-issuer-scoped key
def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str:
r = requests.post(
f"{KS}/v1/admin/licenses",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"product_slug": product_slug,
"policy_slug": "default",
"buyer_email": buyer_email,
"buyer_note": reason,
},
timeout=10,
)
r.raise_for_status()
return r.json()["license_key"]
# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key
```
That's the entire pattern. The agent doesn't need full admin — just the
license-issuer role. If it ever gets compromised, you revoke the scoped key
in the admin UI and generate a new one in 30 seconds.
---
## What's NOT exposed to agents
Some operations are deliberately operator-only and not accessible to any
scoped key, including `full-admin`:
- Generating / revoking scoped API keys (`/v1/admin/api-keys`)
- Disconnecting a payment provider, and connecting *any* provider on a
production daemon
- Setting the operator name
- Activating the self-license (`/v1/admin/self-license`)
- Resetting the analytics install_uuid
- Changing the web UI password (StartOS Action only)
These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent
that can rotate its own credentials, redirect settled payments, or change the
operator identity is no longer bounded by the role it was given.
**One narrow exception — agent-delegated payment connect.** A key granted the
à-la-carte `payment_providers:write` scope (never granted by any role —
operators add it explicitly per key) CAN initiate a BTCPay connect, but only
fail-closed under two gates: the daemon must be in **sandbox mode** (an outer
gate — scoped connect is refused outright on a production daemon, even for
regtest), and the target store must be **non-mainnet** (an inner gate enforced
after the OAuth round-trip). Disconnecting a provider, and any connect on a
production / mainnet daemon, remain master-only. This lets an integrating agent
wire up a throwaway sandbox without ever touching a live store's settlement.
---
## Help us improve this guide
The OpenAPI spec is the source of truth for the API surface. This guide is a
hand-curated overlay focused on the workflows we've seen agents actually need.
If you're building something the spec covers but this guide doesn't make
obvious, open an issue at github.com/keysat-xyz/keysat with the workflow
shape and we'll add it.