Files
keysat-docs/agent.html
T
Keysat 9e4c36c05b Agent integration: new docs page + sidebar entry across all docs
Ports the in-repo KEYSAT_AGENT_GUIDE.md into the docs site as a
first-class page rather than linking out to a raw markdown file on
GitHub. The page covers authentication, scoped API keys, OpenAPI
discovery, error envelope conventions, common workflows (issue /
revoke / find / cancel / change-tier / free-machine), webhook
event types + signature verification, robust-agent patterns, a
"comp-license-via-email" recipe, and the operator-only
operations that aren't exposed to any scoped key.

Sidebar gains an "Agent integration" entry under Get started on
every page (index, install, integrate, wire-format, operate, agent
itself). Docs index "These docs cover" + "Where to next" grids
each gain a third card pointing at the agent guide so it's
discoverable from the introduction page even for visitors who
don't scan the sidebar.
2026-05-11 17:48:50 -05:00

306 lines
18 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keysat Docs — Agent integration</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="docs.css">
</head>
<body>
<div class="topnav">
<a href="https://keysat.xyz" class="brand" title="Back to keysat.xyz"><img src="assets/keysat-mark.svg" alt=""><span>Keysat</span></a>
<span class="docs-tag">Docs</span>
</div>
<div class="layout">
<aside class="side">
<div class="group">
<div class="glabel">Get started</div>
<a href="index.html">Introduction</a>
<a href="install.html">Install &amp; setup</a>
<a href="integrate.html">Integrate the SDK</a>
<a href="agent.html" class="active">Agent integration</a>
</div>
<div class="group">
<div class="glabel">Concepts</div>
<a href="index.html#architecture">Architecture</a>
<a href="index.html#products-policies">Products &amp; policies</a>
<a href="index.html#discounts">Discount codes</a>
<a href="index.html#revocation">Revocation strategy</a>
</div>
<div class="group">
<div class="glabel">Reference</div>
<a href="wire-format.html">Wire format</a>
<a href="integrate.html#api">Admin API</a>
<a href="integrate.html#sdks">SDKs</a>
</div>
<div class="group">
<div class="glabel">Operate</div>
<a href="operate.html#backups">Backups</a>
<a href="operate.html#migrate">Migrate hardware</a>
<a href="operate.html#troubleshooting">Troubleshooting</a>
</div>
</aside>
<main class="prose">
<div class="crumb">Get started · Agent integration</div>
<h1>Agent integration guide.</h1>
<p class="lead">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, an OpenAPI 3.1 spec drives discovery, scoped API keys grant least-privilege access without exposing the master credential, errors carry stable machine-readable codes, and webhooks let an agent react to events instead of polling.</p>
<p>This guide covers the <em>operator side</em> of Keysat &mdash; running, configuring, and performing day-to-day operations. For the <em>buyer side</em> (validating licenses inside your app), see <a href="integrate.html">Integrate the SDK</a>.</p>
<h2 id="quick-start">Quick start</h2>
<pre><code># 1. Discover the API surface
curl https://your-keysat-host/v1/openapi.json
# 2. Generate a scoped API key (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_..."</code></pre>
<h2 id="auth">Authentication</h2>
<p>All admin endpoints use HTTP Bearer auth:</p>
<pre><code>Authorization: Bearer &lt;token&gt;</code></pre>
<p>Two kinds of tokens are accepted.</p>
<p><strong>Master admin API key</strong> &mdash; the env-configured <code>KEYSAT_ADMIN_API_KEY</code> (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.</p>
<p><strong>Scoped API keys</strong> &mdash; additional tokens generated in admin UI → Settings → API keys. Each carries a role that bounds what it can do. Format: <code>ks_&lt;43 chars&gt;</code>. Operators can revoke any scoped key from the same UI; revoked tokens stop working immediately.</p>
<h3>Role to scope mapping</h3>
<table>
<thead><tr><th>Role</th><th>What it can do</th></tr></thead>
<tbody>
<tr><td><code>read-only</code></td><td>List / get every resource. Mutate nothing.</td></tr>
<tr><td><code>license-issuer</code></td><td>All <code>read-only</code> scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes.</td></tr>
<tr><td><code>support</code></td><td>All <code>license-issuer</code> scopes + cancel subscriptions + force-deactivate machines.</td></tr>
<tr><td><code>full-admin</code></td><td>Every scope. Equivalent to the master key for most endpoints.</td></tr>
</tbody>
</table>
<p>Endpoints that touch settings (operator name, payment provider connections, self-license activation, scoped API key management) always require the master admin key. A <code>full-admin</code> scoped key cannot, for example, generate another scoped key &mdash; that's a self-defeating elevation path.</p>
<h2 id="discovery">Discovering the API</h2>
<p>Two complementary discovery mechanisms.</p>
<h3>OpenAPI 3.1 spec</h3>
<p><code>GET /v1/openapi.json</code> &mdash; unauthenticated. Returns a curated spec covering the agent-relevant subset of endpoints. Use this with:</p>
<ul>
<li><strong>OpenAI Custom GPTs</strong>: paste the URL as an Action.</li>
<li><strong>OpenAI Assistants / Functions</strong>: feed the spec to tool definition generators.</li>
<li><strong>Claude tool use</strong>: derive your <code>tools</code> array from the spec; Claude Code agents can <code>WebFetch</code> the spec at runtime.</li>
<li><strong>LangChain / AutoGen / Smolagents</strong>: use their OpenAPI loaders.</li>
<li><strong>Code generation</strong>: <code>openapi-generator-cli generate -i /v1/openapi.json -g python -o ./client</code>.</li>
</ul>
<p>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.</p>
<h3>Embedded endpoint listing</h3>
<p>This guide's <a href="#workflows">Common workflows</a> section below covers the most common agent tasks with copy-paste examples.</p>
<h2 id="envelope">Response envelope conventions</h2>
<p>Every error response uses the same JSON envelope:</p>
<pre><code>{
"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"
}</code></pre>
<p><code>error</code> is a stable machine-readable code; <code>message</code> is human-readable. The <code>upgrade_url</code> field appears on 402 (tier cap) responses so a UI can render an upgrade CTA without parsing message strings.</p>
<h3>Error codes</h3>
<table>
<thead><tr><th>HTTP</th><th><code>error</code> code</th><th>When</th></tr></thead>
<tbody>
<tr><td>400</td><td><code>bad_request</code></td><td>Malformed body, missing required field, invalid enum value</td></tr>
<tr><td>401</td><td><code>unauthorized</code></td><td>No <code>Authorization: Bearer</code> header</td></tr>
<tr><td>403</td><td><code>forbidden</code></td><td>Wrong token, revoked scoped key, role doesn't grant required scope</td></tr>
<tr><td>404</td><td><code>not_found</code></td><td>Resource id doesn't exist</td></tr>
<tr><td>409</td><td><code>conflict</code></td><td>Slug collision, delete-with-references blocked, etc.</td></tr>
<tr><td>402</td><td><code>tier_cap</code></td><td>Operator's self-tier doesn't include the required entitlement</td></tr>
<tr><td>429</td><td><code>rate_limited</code></td><td>Rate limit hit (e.g. /v1/recover, /v1/validate)</td></tr>
<tr><td>502</td><td><code>upstream_error</code></td><td>BTCPay / Zaprite call failed</td></tr>
<tr><td>503</td><td><code>service_unavailable</code> / <code>btcpay_not_configured</code></td><td>Provider not yet connected</td></tr>
<tr><td>500</td><td><code>internal_error</code></td><td>Bug. Includes a trace id in logs; report it.</td></tr>
</tbody>
</table>
<h3>Validate response</h3>
<p><code>POST /v1/validate</code> is the one endpoint that returns 200 in all cases. Inspect <code>ok</code> + <code>reason</code>:</p>
<table>
<thead><tr><th><code>reason</code></th><th>Meaning</th></tr></thead>
<tbody>
<tr><td><code>bad_signature</code></td><td>Signature doesn't verify against the trust-root pubkey</td></tr>
<tr><td><code>not_found</code></td><td>License key not in the daemon's DB</td></tr>
<tr><td><code>revoked</code></td><td>Operator revoked it</td></tr>
<tr><td><code>suspended</code></td><td>Operator suspended it (reversible)</td></tr>
<tr><td><code>expired</code></td><td>Past <code>expires_at</code></td></tr>
<tr><td><code>fingerprint_mismatch</code></td><td>Different machine than the one bound on first activate</td></tr>
<tr><td><code>product_mismatch</code></td><td>License is for a different product than the caller asserted</td></tr>
<tr><td><code>machine_cap_exceeded</code></td><td>Activating this fingerprint would exceed <code>max_machines</code></td></tr>
</tbody>
</table>
<h2 id="workflows">Common workflows</h2>
<h3>Issue a comp license</h3>
<pre><code>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"
}'</code></pre>
<p>Returns the issued license object including <code>license_key</code>. The buyer pastes the key into their app; subsequent validate calls return <code>ok: true</code> with the policy's entitlements.</p>
<p><em>Scope required: <code>licenses:write</code> (any role except <code>read-only</code>).</em></p>
<h3>Revoke a license</h3>
<pre><code>curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \
-H "Authorization: Bearer ks_..." \
-H "Content-Type: application/json" \
-d '{"reason":"refund issued"}'</code></pre>
<p>Idempotent. The next online validate from the buyer's app returns <code>reason: revoked</code>.</p>
<p><em>Scope required: <code>licenses:write</code>.</em></p>
<h3>Find a license by email</h3>
<pre><code>curl "$KS/v1/admin/licenses?buyer_email=alice@example.com" \
-H "Authorization: Bearer ks_..."</code></pre>
<p>Returns matching licenses (without the <code>license_key</code> field &mdash; that's only returned on issue / recover). Use the <code>id</code> for follow-up operations.</p>
<p><em>Scope required: <code>licenses:read</code>.</em></p>
<h3>Cancel a buyer's subscription</h3>
<pre><code># 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"}'</code></pre>
<p>License stays valid through the current cycle's <code>expires_at</code>. Renewal worker stops issuing new invoices.</p>
<p><em>Scope required: <code>subscriptions:write</code>.</em></p>
<h3>Free a machine seat</h3>
<pre><code>curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \
-H "Authorization: Bearer ks_..." \
-d '{"reason":"buyer moved devices"}'</code></pre>
<p>The seat opens up. The buyer's next validate from any machine takes the freed seat.</p>
<p><em>Scope required: <code>machines:write</code>.</em></p>
<h3>Programmatic tier change (comp upgrade)</h3>
<pre><code>curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \
-H "Authorization: Bearer ks_..." \
-d '{
"target_policy_slug": "pro",
"reason": "support resolution"
}'</code></pre>
<p>Always applies as comp (no invoice) from the admin path. Buyer-initiated paid upgrades go through <code>/v1/upgrade</code> (different endpoint, signed-license auth).</p>
<p><em>Scope required: <code>licenses:write</code>.</em></p>
<h2 id="webhooks">Webhooks &mdash; react to events instead of polling</h2>
<p>Configure webhook endpoints in admin UI → Webhooks. The daemon POSTs JSON payloads, HMAC-SHA256 signed with the endpoint's secret, on these events:</p>
<table>
<thead><tr><th>Event</th><th>Fires on</th></tr></thead>
<tbody>
<tr><td><code>license.issued</code></td><td>New license minted (purchase, comp, redeem)</td></tr>
<tr><td><code>license.revoked</code> / <code>license.suspended</code> / <code>license.unsuspended</code></td><td>Admin operations</td></tr>
<tr><td><code>license.tier_changed</code></td><td>Tier upgrade/downgrade applied</td></tr>
<tr><td><code>invoice.paid</code></td><td>A BTCPay / Zaprite invoice settled</td></tr>
<tr><td><code>subscription.renewal_pending</code></td><td>Renewal worker created a fresh invoice</td></tr>
<tr><td><code>subscription.renewal_skipped</code></td><td>Renewal skipped (e.g. policy archived)</td></tr>
<tr><td><code>subscription.cancelled</code></td><td>Buyer or admin cancelled</td></tr>
<tr><td><code>subscription.lapsed</code></td><td>Past-due grace expired</td></tr>
<tr><td><code>machine.activated</code></td><td>First validate from a new fingerprint</td></tr>
</tbody>
</table>
<p>Verify signatures:</p>
<pre><code>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)</code></pre>
<p>The header is <code>X-Keysat-Signature</code>. Failed deliveries retry with exponential backoff up to 10 attempts; permanently-failed deliveries land in the DLQ visible at admin UI → Webhooks → Failed.</p>
<h2 id="robust">Designing a robust agent</h2>
<p>A few patterns that work well in practice.</p>
<h3>Idempotency</h3>
<p>The daemon's mutation endpoints are idempotent where they can be. Revoke, suspend, unsuspend, archive, unarchive, subscription cancel &mdash; all return success on the second call without changing state. Your agent can safely retry on network errors.</p>
<h3>Pagination</h3>
<p>List endpoints return up to ~100 rows by default. Use <code>?limit=N</code> and <code>?offset=N</code> for larger result sets. The OpenAPI spec documents the limits per endpoint.</p>
<h3>Rate limits</h3>
<p>The admin endpoints have no per-IP rate limit today &mdash; operators are trusted. The public endpoints (<code>/v1/validate</code>, <code>/v1/recover</code>) are rate-limited per client IP (10/min for <code>/recover</code>; <code>/validate</code> is unlimited but a reasonable agent calls it once per app boot + once per hour).</p>
<h3>Master key handling</h3>
<p>If your automation needs <code>full-admin</code> 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, <strong>always use a scoped key</strong>. Operators can revoke a compromised scoped key without rotating the master credential.</p>
<h3>Backoff on 5xx</h3>
<p><code>internal_error</code> (500) is a bug or a transient DB lock. Retry with exponential backoff (1s, 2s, 4s, 8s, give up). Don't retry on 4xx &mdash; those are deterministic client errors.</p>
<h2 id="recipe">Concrete recipe &mdash; "Comp a license to anyone who emails support@"</h2>
<pre><code>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</code></pre>
<p>That's the entire pattern. The agent doesn't need full admin &mdash; 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.</p>
<h2 id="not-exposed">What's NOT exposed to agents</h2>
<p>Some operations are deliberately operator-only and not accessible to any scoped key, including <code>full-admin</code>:</p>
<ul>
<li>Generating / revoking scoped API keys (<code>/v1/admin/api-keys</code>)</li>
<li>Connecting / disconnecting payment providers</li>
<li>Setting the operator name</li>
<li>Activating the self-license (<code>/v1/admin/self-license</code>)</li>
<li>Resetting the analytics install_uuid</li>
<li>Changing the web UI password (StartOS Action only)</li>
</ul>
<p>These all require the master <code>KEYSAT_ADMIN_API_KEY</code>. The reasoning: an agent that can rotate its own credentials, connect arbitrary payment processors, or change the operator identity is no longer bounded by the role it was given.</p>
<h2 id="feedback">Help us improve this guide</h2>
<p>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 <a href="https://github.com/keysat-xyz/keysat">github.com/keysat-xyz/keysat</a> with the workflow shape and we'll add it.</p>
</main>
<aside class="toc">
<div class="label">On this page</div>
<a href="#quick-start">Quick start</a>
<a href="#auth">Authentication</a>
<a href="#discovery">Discovering the API</a>
<a href="#envelope">Response envelope</a>
<a href="#workflows">Common workflows</a>
<a href="#webhooks">Webhooks</a>
<a href="#robust">Designing a robust agent</a>
<a href="#recipe">Recipe: comp-license bot</a>
<a href="#not-exposed">Not exposed to agents</a>
</aside>
</div>
</body>
</html>