# 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](/docs/enrol) 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](/docs/integrators) |
| `POST /v1/keys/rotate` | a replacement key, 24 h grace |

Parameters, examples and every response shape are on the [REST API reference](/docs/rest);
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:

```json
{ "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.
