# REST API reference

Every tool on the remote MCP server is also a plain-HTTP route under `https://app.solospace.dev/v1/`. This page
is the route table, Stripe-style: one section per endpoint, the scope it needs, its parameters,
a curl call and what comes back. The machine-readable version is
[`GET /v1/openapi.json`](https://app.solospace.dev/v1/openapi.json) — same routes, same shapes, no bearer needed
to fetch it.

The facade is not a second API. Each route authenticates the bearer exactly as `/mcp` does,
calls the same tool with the same arguments, and returns the very object an MCP client gets in
`structuredContent`. Nothing a key can do here is something it could not do over `/mcp`.

## Authentication

```
Authorization: Bearer apk_key_…
```

The bearer is an [API key](/docs/api-keys) minted on the dashboard, or an automation token, or
an OAuth access token a connected agent already holds. All three are the same kind of grant: the
accounts it may touch, the scopes it holds and its expiry were fixed when a human minted it, and
no argument to any route can widen them.

| Scope | Unlocks |
|---|---|
| none | `GET /v1/access`, `GET /v1/openapi.json` |
| `connectors:read` | The catalogue, one connector's status, notes, automations |
| `signin` | Tickets, the page script, the fallback signature, runs |
| `approvals:read` | The open approvals of this connection, and one by id |
| `audit:read` | This connection's own audit rows |
| `enrol` | Adding a passkey by API, its status, confirming or withdrawing it; creating and reading subjects |
| `credentials:write` | Revoking passkeys and subjects this key manages |

**Coverage.** A key minted with `enrol` has `coverEnrolled` on by default: every credential it
enrols is automatically covered by the key, so an integrator does not mint a new key per customer.
Turn it off on the API-key form to keep a key's accounts fixed to what was ticked.

## Conventions

**The envelope.** Every response body — success, pending, refusal, mistake — carries the same
four fields first, then the payload:

```json
{
  "state": "ok | pending | denied | error",
  "retryable": false,
  "terminal": true,
  "next": { "kind": "tool", "tool": "…", "arguments": {}, "waitMs": 0, "why": "…" },
  "code": "a_stable_token",
  "because": "One or two sentences for the transcript a human will read."
}
```

`retryable` and `terminal` are derived from `state` on the server; they never disagree with it.
`next` is the literal next move — a tool name and arguments an agent can call as written, an
`authorize` step naming the scope, or `ask_human` — or `null` when there is genuinely nothing
to do.

**Status codes follow `state`.** The status is a courtesy for a script that reads only the
status line; the body is the contract.

| Status | `state` | Meaning |
|---|---|---|
| **200** | `ok` | Done. Read the payload. |
| **202** | `pending` | A human is being asked, on their phone, right now. Wait `next.waitMs`, then poll the route `next` names. |
| **403** | `denied` | A decision on the merits — human, policy or origin said no. Terminal. Do not retry. |
| **403** + `WWW-Authenticate: Bearer error="insufficient_scope"` | `needs_authorization` | The key was never granted this scope. Mint one that has it; retrying changes nothing. |
| **401** | — | The key is expired, revoked or unknown. |
| **400** | `error` | A mistake in the call. `because` says which. |
| **404** | `error` | `not_found`, `ticket_unknown`, `ticket_expired`. |
| **429** | `error` | `too_many_tickets` — the per-connection ceremony cap. |

**Bodies.** A POST body is the tool's arguments as one JSON object; a path parameter is laid over
it, so `/v1/runs/<id>/end` cannot be told about a different run in its body. GET routes take
their arguments from the path; where a GET's tool has optional arguments (audit paging, an
approval wait) they go in the query string.

**Tickets are idempotent handles.** `POST /v1/signin/begin` and `POST /v1/enrol/begin` each
mint a single-use ticket good for a few minutes. Polling the ticket's GET is free and safe to
repeat; presenting the ticket a second time after it is `used` or `created` does nothing new.
Minting a fresh ticket for the same credential while one is `in_flight` raises a *second*
question to the same human — poll the one you have.

**Byte-identical polls.** On `202`, the next call must carry exactly the arguments the first one
did. A changed origin, a re-encoded `clientDataJSON` or a different credential is a new question,
and pushes another notification at the same person.

**CORS and caching.** Every route answers `cache-control: no-store` and the same CORS headers as
`/mcp`; `OPTIONS /v1/*` is unauthenticated.

## Access

### `GET /v1/access` — no scope

What this key may do right now: the scopes it holds and the ones it lacks, which routes are
usable, which sites are connected, and when the token expires. Signs nothing, wakes nobody.

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/access
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "client": "nightly-report",
  "scopes": { "held": ["connectors:read", "signin"], "missing": [{ "scope": "enrol", "unlocks": "…" }] },
  "tools": [{ "name": "passkey_begin_signin", "usable": true, "why": "…" }],
  "expiresAt": "2026-10-16T09:00:00.000Z" }
```

## Connectors

### `GET /v1/connectors` — `connectors:read`

The catalogue, narrowed to this grant. Each connector carries `notes` first — where the passkey
option hides on that site — then `rpId`, `credentialId`, `userName`, `signinUrl`, `origins`,
`originAdvice`, `mode`, `lastUsedAt` and, for a credential enrolled for a subject,
`subject: { id, externalId, displayLabel }`. A site that is missing was not granted.

| Param | In | Meaning |
|---|---|---|
| `subjectId` | query, optional | Only the connectors of one [subject](#subjects). |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" "$APK/v1/connectors?subjectId=sub_…"
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "grant": { "accounts": ["github.com as octocat"], "scopes": ["connectors:read", "signin"], "expiresAt": "…" },
  "connectors": [{ "rpId": "github.com", "credentialId": "b64url…", "userName": "octocat",
                   "signinUrl": "https://github.com/login", "mode": "approval",
                   "notes": "…", "notesSource": "connector", "lastUsedAt": "…" }],
  "count": 1 }
```

### `GET /v1/connectors/:domain` — `connectors:read`

What the service has recorded about one connector: its mode, its notes, its last successful
signature, and whether a signature is waiting on a human right now. It cannot see your browser.

| Param | In | Meaning |
|---|---|---|
| `domain` | path | The domain exactly as `/v1/connectors` spells it. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/connectors/github.com
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "domain": "github.com", "connector": { "…": "…" }, "confidence": "recorded",
  "lastSignedAt": "…", "waitingForApproval": false, "pendingApprovals": [] }
```

A domain outside the grant answers **404** with the domains that are inside it.

### `POST /v1/connectors/:domain/notes` — `connectors:read`

Write back what you learned about a site. Stored as provisional, attributed to this connection,
shown to the owner. It gates nothing.

| Param | In | Meaning |
|---|---|---|
| `domain` | path | The connected domain the note is about. |
| `kind` | body | `"login"` — how to get in. `"site"` — how to work the site once in. |
| `note` | body | One or two sentences of prose. No selectors, no secrets. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"kind":"login","note":"The passkey option is behind Try another way."}' \
     $APK/v1/connectors/github.com/notes
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "noteId": "…", "domain": "github.com", "kind": "login", "status": "provisional", "truncated": false }
```

## Sign-in

### `POST /v1/signin/begin` — `signin`

**Start here.** Mints a single-use ceremony ticket for one credential and returns `armUrl`:
navigate a browser that has the Agent Passkey extension to it, and the extension answers that
tab's next matching passkey prompt from its background worker — on any site, strict CSP included.
The result also carries `shim`, a page script for a browser without the extension, which works
where the site's CSP allows the page to reach this service (`shimProcedure` says whether it does).

| Param | In | Meaning |
|---|---|---|
| `credentialId` | body | From `/v1/connectors`, base64url. Checked against the live grant. |
| `expectAccount` | body, optional | Which account you mean, when the grant holds more than one. The ticket is refused at mint and at redemption unless the credential belongs to it. |
| `subjectId` | body, **required for a subject-bound credential** | The subject the credential belongs to. Omitted or wrong → **403** `subject_mismatch`. |
| `runId` | body, optional | An open automation run, so the signature is audited under it. |
| `pageUrl` | body, optional | The https page showing the passkey prompt now; `armUrl` returns there. Must be on the connector's site. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"credentialId":"<from /v1/connectors>","subjectId":"sub_…"}' $APK/v1/signin/begin
```

```json
{ "state": "ok", "retryable": false, "terminal": true,
  "next": { "kind": "browser", "action": "navigate", "url": "https://app.solospace.dev/arm#ticket=5f0c…&to=https%3A%2F%2Fgithub.com%2Flogin",
            "why": "Navigate to armUrl, then click the passkey button on github.com. …" },
  "ticket": "5f0c…-…", "status": "unused", "expiresAt": "2026-09-16T17:37:09.000Z",
  "armUrl": "https://app.solospace.dev/arm#ticket=5f0c…&to=https%3A%2F%2Fgithub.com%2Flogin",
  "site": "github.com", "account": "octo@example.com", "aliases": ["octocat"], "mode": "approval",
  "signinUrl": "https://github.com/login",
  "ticketPath": { "likely": false, "why": "connect-src does not allow this service." },
  "ifNotArmed": { "kind": "ask_human", "say": "Agent Passkey cannot press the passkey prompt on github.com for me in this browser. …" } }
```

**403** `denied` with `code: "policy_denied"`, `origin_rejected` or `subject_mismatch` is
terminal. **429** `too_many_tickets` means poll the ticket you already have.

### `GET /v1/signin/:ticket` — `signin`

The server's view of one ticket. Free, safe to repeat.

| `phase` | Meaning |
|---|---|
| `unused` | No page has presented it yet — drive the page to the passkey prompt. |
| `in_flight` | A page is presenting it right now. |
| `pending_approval` | A human is being asked (**202**). The page re-presents the ticket on its own. |
| `used` | Its one signature was produced. Watch the site for the navigation. |
| `denied` | Terminal (**403**), with `code` and `because`. |
| `expired` / `unknown` | **404**. Mint another. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/signin/5f0c…
```

```json
{ "state": "pending", "retryable": true, "terminal": false,
  "next": { "kind": "tool", "tool": "passkey_signin_status", "arguments": { "ticket": "5f0c…" }, "waitMs": 25000, "why": "A human is being asked." },
  "code": "waiting_for_approval", "ticket": "5f0c…", "status": "pending_approval",
  "approvalId": "…", "expiresAt": "…",
  "history": { "attempt": 1, "expiresAt": "…", "prior": [] } }
```

### `POST /v1/signin/complete` — `signin`

The **fallback**, for a page whose CSP blocks it from reaching the service itself. The caller
ferries `clientDataJSON` in and `authData` / `signature` / `userHandle` out.

| Param | In | Meaning |
|---|---|---|
| `credentialId` | body | base64url. |
| `clientDataJSON` | body | base64url, **byte-for-byte as the page built it**. One differing byte produces a signature that verifies against nothing. |
| `origin` | body | The page's origin — scheme and host, no path. Must share a registrable domain with an enrolled origin. |
| `waitUpToMs` | body, optional | Let the server hold the call open for the human's tap instead of polling. Clamped; the result says `waitedMs`. Not part of the approval binding. |
| `subjectId` | body, required for a subject-bound credential | As for `/v1/signin/begin`. |
| `expectAccount`, `runId` | body, optional | As above. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"credentialId":"…","clientDataJSON":"eyJ0eXBl…","origin":"https://github.com","waitUpToMs":25000}' \
     $APK/v1/signin/complete
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "credentialId": "…", "authData": "b64url…", "signature": "b64url…", "userHandle": "b64url…",
  "flags": { "up": true, "uv": true }, "approvedBy": "human" }
```

**202** carries `approvalId` and `code: "waiting_for_approval"` — resend the identical body.
**403** carries one of `approval_denied`, `approval_timeout`, `policy_denied`,
`origin_rejected`, `subject_mismatch`, `argument_mismatch` (you changed the arguments while a
human was being asked).

## Approvals

### `GET /v1/approvals` — `approvals:read`

The approvals this connection has raised that are still open, each with its deadline. Approves
nothing, hurries nobody.

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/approvals
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "approvals": [{ "approvalId": "…", "state": "pending", "rpId": "github.com",
                  "origin": "https://github.com", "createdAt": "…", "expiresAt": "…" }],
  "count": 1 }
```

### `GET /v1/approvals/:id` — `approvals:read`

One approval, by the id a pending result carried, with its full `history` (every earlier
prompt and how it ended). Use it instead of re-sending the sign-in call, which would notify the
person again.

| Param | In | Meaning |
|---|---|---|
| `id` | path | The `approvalId`. |
| `waitUpToMs` | query, optional | Hold the call open until a decision or the wait runs out. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" "$APK/v1/approvals/<id>?waitUpToMs=25000"
```

**200** `approved` or **403** `approval_denied` / `approval_timeout`; **202** while the human
is still being asked. An approval that is not this connection's answers **404**.

## Enrolment

The full procedure, the flow diagram and the limits are on
[Adding passkeys via the API](/docs/enrol). The routes:

### `POST /v1/enrol/begin` — `enrol`

Mints an enrolment ticket and the page script that patches `navigator.credentials.create()`.
The human must already be signed in to the site in the browser you drive; this service cannot
do that step.

| Param | In | Meaning |
|---|---|---|
| `domain` | body | The relying party, e.g. `github.com`. `rpId` is accepted as a synonym — the extension's own field name. |
| `origin` | body, optional | The page origin the ceremony will run on. Checked against `domain`; recorded as the enrolled origin. |
| `signinUrl` | body, optional | Where a later sign-in should start. |
| `userName` | body, optional | The account name to record; the site's own `user.name` wins if the page supplies one. |
| `userHandle` | body, optional | base64url `user.id`, if you already know it. |
| `displayLabel` | body, optional | How the passkey is labelled on the dashboard. |
| `subjectId` | body, optional | Bind the credential to a [subject](#subjects). Every later sign-in must then name it. |
| `policy` | body, optional | `{ "mode": "session", "expiresAt": "<ISO, at most 30 days out>" }` for unattended signing within a bounded window. Omitted → approval mode. `"silent"` → **400** `policy_refused`; an unbounded or longer window → **400** `policy_refused`. |
| `notes` / `siteNotes` | body, optional | Operating notes to store with the new connector. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"domain":"github.com","signinUrl":"https://github.com/login","userName":"octocat"}' \
     $APK/v1/enrol/begin
```

```json
{ "state": "ok", "retryable": false, "terminal": true,
  "next": { "kind": "browser", "why": "Evaluate shim on the site's add-a-passkey page, then click its Add passkey button." },
  "ticket": "9a41…", "expiresAt": "2026-09-16T17:42:00.000Z", "rpId": "github.com",
  "shim": "(()=>{ /* patches navigator.credentials.create */ })()" }
```

### `GET /v1/enrol/:ticket` — `enrol`

Where the enrolment ticket is: `unused`, `created` (the page received an attestation),
`confirmed`, `withdrawn`, `expired`. Carries `credentialId` and the new `connector` once created.

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/enrol/9a41…
```

```json
{ "state": "ok", "retryable": false, "terminal": true,
  "next": { "kind": "tool", "tool": "passkey_complete_enrol", "arguments": { "ticket": "9a41…", "ok": true }, "why": "Tell the service whether the site accepted it." },
  "status": "created", "credentialId": "b64url…",
  "connector": { "rpId": "github.com", "userName": "octocat", "mode": "approval" } }
```

### `POST /v1/enrol/complete` — `enrol`

The site's verdict, which the service cannot see. `ok: true` confirms the connector;
`ok: false` withdraws it, and the credential is revoked in the same transaction.

| Param | In | Meaning |
|---|---|---|
| `ticket` | body | From `/v1/enrol/begin`. `credentialId` is accepted in its place, for a caller that kept the credential and not the ticket. |
| `ok` | body | `true` if the site accepted the passkey. |
| `detail` | body, optional | What the site said, in one sentence. Audited, never interpreted. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"ticket":"9a41…","ok":true}' $APK/v1/enrol/complete
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "status": "confirmed", "credentialId": "b64url…" }
```

### `POST /v1/credentials` — `enrol`

The **direct** path, for a caller that runs the registration ceremony itself and only needs the
attestation. It returns what `navigator.credentials.create()` would have: the caller hands it to
the site.

| Param | In | Meaning |
|---|---|---|
| `domain` | body | The relying party id. |
| `origin` | body | The page origin the ceremony runs on. Checked against `domain`. |
| `userName` | body | The account name. |
| `userHandle` | body, optional | base64url `user.id` from the site's options, if it gave one. |
| `signinUrl`, `displayLabel`, `notes`, `siteNotes` | body, optional | As for `/v1/enrol/begin`. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"domain":"github.com","origin":"https://github.com","userName":"octocat"}' \
     $APK/v1/credentials
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "credentialId": "b64url…", "attestationObject": "b64url…", "authData": "b64url…",
  "publicKeyDer": "b64url…", "transports": ["internal"], "credProps": { "rk": true } }
```

### `GET /v1/credentials/:id` — `connectors:read`

One passkey, in full: who it belongs to, where it may sign, how it got here and what policy
governs it. `enrolledVia` is `extension` or `api`; `status` is `active` or `revoked`.

| Param | In | Meaning |
|---|---|---|
| `id` | path | The `credentialId`, base64url. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/credentials/<credentialId>
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "credentialId": "b64url…", "rpId": "github.com", "origins": ["https://github.com"],
  "userName": "octocat", "displayLabel": "GitHub · octocat",
  "subject": { "id": "sub_…", "externalId": "cust_8812", "displayLabel": "Acme Ltd" },
  "status": "active", "enrolledVia": "api", "enrolledAt": "…", "lastUsedAt": "…",
  "policy": { "mode": "session", "expiresAt": "…", "rateLimitPerHr": 60 } }
```

A credential outside this grant — or another tenant's — answers **404**, never 403: the id's
existence is not confirmed.

### `POST /v1/credentials/:id/revoke` — `credentials:write`

Revokes one passkey. Every grant that covered it loses it at once, and its audit rows stay.
Idempotent: a second call answers **200** with `already: true`.

| Param | In | Meaning |
|---|---|---|
| `id` | path | The `credentialId`, base64url. |

```bash
curl -sS -X POST -H "Authorization: Bearer $APK_KEY" $APK/v1/credentials/<credentialId>/revoke
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "credentialId": "b64url…", "revoked": true, "already": false }
```

A credential this key does not manage, or another tenant's, answers **404**. Revoking is the one
destructive act in the API, which is why it has its own scope and why neither `enrol` nor
`credentials:write` is granted by default.

## Subjects

A **subject** is an end customer of *your* product — a tenant-owned identity that owns passkeys
you enrol on their behalf. Subjects exist so a relay that signs many people in can keep their
credentials apart, name which one it means on every call, and revoke one customer without
touching the rest. A credential bound to a subject can only be signed with that subject named.

### `POST /v1/subjects` — `enrol`

Create a subject, or return the existing one with the same `externalId` (idempotent). The
consent block records that the person agreed to this: the text they saw, when, and — hashed at
rest, never readable back — the IP and user agent they agreed from.

| Param | In | Meaning |
|---|---|---|
| `externalId` | body | Your identifier for the customer. Unique per key owner. |
| `displayLabel` | body, optional | How the dashboard names them. |
| `consent.text` | body | The consent wording the person saw. |
| `consent.at` | body, optional | ISO-8601. Defaults to now. |
| `consent.ip`, `consent.userAgent` | body, optional | Stored as hashes. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"externalId":"cust_8812","displayLabel":"Acme Ltd",
          "consent":{"text":"Let Acme Relay sign in to GitHub on my behalf.","ip":"203.0.113.9"}}' \
     $APK/v1/subjects
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "subjectId": "sub_…", "externalId": "cust_8812", "displayLabel": "Acme Ltd",
  "status": "active", "createdAt": "…", "existed": false }
```

### `GET /v1/subjects` — `enrol`

Every subject this key owner has, newest first, each with its status and credential count.

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/subjects
```

### `GET /v1/subjects/:id` — `enrol`

One subject, with its credentials in the same shape `GET /v1/credentials/:id` returns.

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/subjects/sub_…
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "subjectId": "sub_…", "externalId": "cust_8812", "displayLabel": "Acme Ltd", "status": "active",
  "consent": { "text": "…", "at": "…" },
  "credentials": [{ "credentialId": "…", "rpId": "github.com", "userName": "octocat", "displayLabel": null, "status": "active", "enrolledAt": "…" }] }
```

### `POST /v1/subjects/:id/revoke` — `credentials:write`

Revokes the subject and **every passkey it owns** in one transaction. Idempotent. The customer
is gone from this service; their audit rows stay.

```bash
curl -sS -X POST -H "Authorization: Bearer $APK_KEY" $APK/v1/subjects/sub_…/revoke
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "subjectId": "sub_…", "revoked": true, "credentialsRevoked": 2, "already": false }
```

## Keys

### `POST /v1/keys/rotate` — the key itself

Mints a replacement for the bearer that made the call — same grant, same accounts, same scopes,
a fresh expiry — and starts a **24-hour grace** in which the old key keeps working. Swap the
secret in your deployment inside that window. The new key is shown once, here.

```bash
curl -sS -X POST -H "Authorization: Bearer $APK_KEY" $APK/v1/keys/rotate
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "key": "apk_key_…", "prefix": "apk_key_7f3c", "expiresAt": "2026-10-16T09:00:00.000Z",
  "previousExpiresAt": "2026-09-17T09:12:00.000Z" }
```

A key that is about to expire says so twice on your webhook — `key.expiring` seven days and one
day before — so rotation can be scheduled rather than discovered as a 401.

## Automations

### `GET /v1/automations` — `connectors:read`

Every automation of this account with its status (`proposed` / `active` / `paused`), trigger,
next due time and last run.

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/automations
```

### `POST /v1/automations` — `connectors:read`

Propose one. Nothing runs until a human activates it on the dashboard; the result carries the
`activateUrl` to send them.

| Param | In | Meaning |
|---|---|---|
| `name` | body | A short name. |
| `instructions` | body | What to do once signed in, in prose. |
| `credentialIds` | body | The connectors it uses; all must be in this grant. |
| `trigger` | body | `{ "kind": "schedule", "cron": "0 9 * * 1-5", "tz": "Asia/Jerusalem" }`, `{ "kind": "webhook" }` or `{ "kind": "manual" }`. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"name":"Morning bounces","instructions":"Open the bounces report and post the count.","credentialIds":["…"],"trigger":{"kind":"manual"}}' \
     $APK/v1/automations
```

```json
{ "state": "ok", "retryable": false, "terminal": true,
  "next": { "kind": "ask_human", "why": "Only the person can activate it." },
  "automationId": "…", "status": "proposed", "name": "Morning bounces",
  "activateUrl": "https://app.solospace.dev/dashboard/automations/…" }
```

## Runs

### `POST /v1/runs` — `signin`

Begin a run. One run at a time per automation; a second call returns the open run rather than
starting another.

| Param | In | Meaning |
|---|---|---|
| `automationId` | body | From `/v1/automations` or a `run.due` event. |
| `runId` | body, optional | Claim the specific scheduled run a `run.due` event named. |
| `trigger` | body, optional | `schedule` / `webhook` / `manual`. Defaults to `manual`. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"automationId":"…"}' $APK/v1/runs
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "runId": "…", "automationId": "…", "name": "Morning bounces", "resumed": false,
  "startedAt": "…", "instructions": "…", "connectors": [ { "…": "…" } ] }
```

An automation that is not `active` answers **403** `denied`.

### `GET /v1/runs/:id` — `signin`

One run in any state: `running`, `succeeded`, `failed`, `needs_human`, `missed`,
`abandoned` — with its trigger, timestamps, summary and the `requestId` its signatures were
audited under.

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/runs/<runId>
```

### `POST /v1/runs/:id/end` — `signin`

End a run. Always call it: a run left open is marked `abandoned` after 30 minutes and blocks the
next one.

| Param | In | Meaning |
|---|---|---|
| `id` | path | The `runId`. |
| `status` | body | `succeeded` / `failed` / `needs_human`. |
| `summary` | body, optional | One or two sentences for the person. Ids and outcomes, never page content. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"status":"succeeded","summary":"Posted 3 bounces to #ops."}' $APK/v1/runs/<runId>/end
```

```json
{ "state": "ok", "retryable": false, "terminal": true, "next": null,
  "runId": "…", "status": "succeeded", "finishedAt": "…" }
```

## Audit

### `GET /v1/audit` — `audit:read`

This connection's **own** audit rows, newest first: every signature it asked for, the verdict,
the reason phrase, the site, the origin, the credential and the `requestId`. Other agents and
the owner's own activity are not readable from here, and no argument widens it.

| Param | In | Meaning |
|---|---|---|
| `limit` | query, optional | 1 to 200. Default 50. |
| `before` | query, optional | ISO-8601. Only rows strictly older — the paging cursor. |
| `subjectId` | query, optional | Only rows about one subject's credentials. |
| `credentialId` | query, optional | Only rows about one passkey. |

```bash
curl -sS -H "Authorization: Bearer $APK_KEY" "$APK/v1/audit?limit=50"
```

```json
{ "state": "ok", "retryable": false, "terminal": true,
  "next": { "kind": "tool", "tool": "passkey_audit", "arguments": { "limit": 50, "before": "…" }, "why": "There may be older rows." },
  "entries": [{ "at": "2026-09-16T17:32:09.000Z", "action": "assert", "verdict": "allowed",
                "reason": "approved by human", "rpId": "google.com",
                "origin": "https://accounts.google.com", "requestId": "…", "credentialId": "…" }],
  "count": 50, "nextBefore": "…" }
```

## Not on this surface

`passkey_signin` (the one-call sign-in) exists only on the local stdio server, which has a
browser; there is no route for it. `passkey_howto` is [the documentation](/docs/agent-skill).
