A long-lived key for scripts and CI: the same grant as an agent's, usable with curl on /v1/* or as a bearer on /mcp.
API keys
An API key is a long-lived bearer for something that is not an MCP client: a shell script, a CI job, a cron. It uses the same tools a connected agent uses, either over /mcp or over the plain-HTTP facade at /v1/*, and nothing a key can do is something an agent could not.
A key is a grant
Minting a key on the dashboard (API keys → New API key) is the consent screen: a name, the permissions, which accounts, whether it signs on its own or asks you each time, and for how long. The permissions are the same scopes an agent gets — connectors:read, signin, approvals:read, audit:read — plus two that are off unless you tick them: enrol ("Add passkeys"), with which a key can add a passkey to a site you have signed in to and create subjects; and credentials:write ("Revoke passkeys"), with which it can revoke the passkeys and subjects it manages. A key holding enrol covers what it enrols (coverEnrolled, on by default), so one key serves every customer it signs up. What that writes is exactly what connecting an agent writes — an oauth_grants row, the unattended-signing policies if you chose them, and one access token of the grant with the grant's own lifetime. The token is the key: apk_key_…, shown once. There is no refresh token, because a script cannot hold a rotating one; "until I revoke it" still ends after 30 days, the ceiling every unattended grant has.
The key is audited under its own name, appears on the Agents page like any connection, and can only ever narrow: an account you did not tick, or a passkey you revoke later, is not reachable whatever the key says.
Using it with curl
export APK_KEY=apk_key_…
export APK=https://app.solospace.dev
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/access
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/connectors
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/connectors/github.com
# sign in: a ticket and the page script, then poll it
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
-d '{"credentialId":"<from /v1/connectors>"}' $APK/v1/signin/begin
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/signin/<ticket>| Method and path | Tool it calls |
|---|---|
GET /v1/access | passkey_check_access |
GET /v1/connectors | passkey_list |
GET /v1/connectors/:domain | passkey_connector_status |
POST /v1/connectors/:domain/notes | passkey_note |
POST /v1/signin/begin | passkey_begin_signin |
GET /v1/signin/:ticket | passkey_signin_status |
POST /v1/signin/complete | passkey_complete_signin |
GET /v1/approvals | passkey_approvals |
GET /v1/automations | passkey_automation_list |
POST /v1/automations | passkey_automation_propose |
POST /v1/runs | passkey_run_begin |
POST /v1/runs/:id/end | passkey_run_end |
GET /v1/runs/:id | passkey_run_status |
GET /v1/approvals/:id | passkey_approval_status |
GET /v1/audit | passkey_audit |
POST /v1/enrol/begin | passkey_begin_enrol — needs the enrol scope |
GET /v1/enrol/:ticket | passkey_enrol_status |
POST /v1/enrol/complete | passkey_complete_enrol |
POST /v1/credentials | passkey_enrol_direct |
GET /v1/credentials/:id | one passkey in full |
POST /v1/credentials/:id/revoke | passkey_revoke — needs credentials:write |
POST /v1/subjects, GET /v1/subjects, GET /v1/subjects/:id, POST /v1/subjects/:id/revoke | subjects |
POST /v1/keys/rotate | a replacement key, 24 h grace |
Parameters, examples and every response shape are on the REST API reference; the machine-readable form is GET /v1/openapi.json.
A POST body is the tool's arguments as a JSON object; a path parameter is laid over it. The response body is the tool result — the same object an MCP client gets in structuredContent, with state, retryable, terminal and next first. The status follows state: 200 ok, 202 pending (a human is being asked; wait next.waitMs and poll), 403 denied (a decision on the merits — do not retry), 400 a mistake in the call, 404 a name nobody has, 429 a cap. A 401 means the key is dead or expired; a 403 with WWW-Authenticate: Bearer error="insufficient_scope" means the key was never granted that permission — mint one that has it.
Using it with an MCP client
Any MCP client that can send a static header skips the OAuth flow entirely:
{ "mcpServers": { "agent-passkey": {
"url": "https://app.solospace.dev/mcp",
"headers": { "Authorization": "Bearer apk_key_…" } } } }Rotating
POST /v1/keys/rotate with the key as its own bearer returns a replacement — same grant, fresh expiry, shown once — and the old key keeps working for 24 hours. The key.expiring webhook fires seven days and one day before a key runs out, so rotation is a scheduled job rather than a surprise 401.
Revoking
API keys → Revoke, with the name typed. The grant, the key, the unattended-signing policies and the materialised agent go in one transaction, and the next request answers 401. Sign-ins it already made stay in the audit log. There is nothing cached to wait out.