# Links

The links on a page, in display order.

```json
{
  "id": "9d2b7c15-4e3a-4b88-9f21-0a6c5d3e7b90",
  "label": "Our catalogue",
  "url": "https://example.com/catalogue",
  "icon": "book",
  "position": 0,
  "visible": true,
  "created_at": "2026-03-01T09:14:02.554311+00:00",
  "updated_at": "2026-03-01T09:14:02.554311+00:00"
}
```

`position` is the display order, ascending. It does not have to be unique or
contiguous; ties break by id, so leaving everything at `0` gives a stable but
arbitrary order. `url` must be `http`, `https`, `mailto:` or `tel:`.

Links are unlimited in practice. A page holds at most **1,000 links**, here and
in the dashboard alike: a safety ceiling the same on every plan, which keeps a
page fast to open on a phone (`links_per_page` in `GET /v1/usage`). A page that
already had more when its ceiling was lowered keeps every one of them: they are
all listed, and each can still be edited, moved or removed. Nothing is added to
it until it is under the ceiling again.

## Operations

### `GET /v1/pages/{slug}/links`

**List a page's links**

Scope `pages:read`. Another workspace's answers `404`.

Every link on the page, in display order, with no cursor. Links are unlimited in practice:
a page holds at most 1,000 links, a safety ceiling the same on every plan
(`links_per_page` in `GET /usage`). A page that already had more when its ceiling was
lowered keeps every one of them, and all of them are listed here.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `slug` | path | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `LinkList` | The links. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

**Every link on a page, in display order** — answers `200`

One response, never paged. The cap is enforced on the write, not only on the read, so there is no link you could create and then not see here.

```bash
curl https://api.boltclaw.ai/v1/pages/acme/links \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/pages/acme/links", {
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
  },
});

// 200
const data = await res.json();
```

### `POST /v1/pages/{slug}/links`

**Add a link**

Scope `pages:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`. **Can make something publicly visible.**

Adds a link to the page. A page holds at most 1,000 links, a safety ceiling the same on
every plan (`links_per_page` in `GET /usage`): adding one to a page at its ceiling is
`409 CONFLICT`. A page that had more when its ceiling was lowered keeps them all, and you can
still edit, reorder and remove them, but nothing is added to it until it is under the
ceiling. A `label` longer than your organisation's `link_label_chars` (120 by default) is
`400`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `slug` | path | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `label` | `string \| null` | 120 characters by default; your organisation's number is `link_label_chars` in `GET /usage`. (max 120) |
| `url` | `string` | (**required**; min 1; max 2048) |
| `icon` | `string \| null` | (max 32) |
| `position` | `integer` | (>= 0; <= 10000) |
| `visible` | `boolean` |  |

```json
{
  "url": "<url>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Link` | Created. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/links/{id}`

**Update a link**

Scope `pages:write`. Another workspace's answers `404`. **Can make something publicly visible.**

Changes the fields you send.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `label` | `string \| null` | 120 characters by default; your organisation's number is `link_label_chars` in `GET /usage`. (max 120) |
| `url` | `string` | (min 1; max 2048) |
| `icon` | `string \| null` | (max 32) |
| `position` | `integer` | (>= 0; <= 10000) |
| `visible` | `boolean` |  |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Link` | The updated link. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

**Move a link** — answers `200`

`position` is ascending and ties break by id, so a page whose links all sit at `0` has a stable but arbitrary order.

```bash
curl -X PATCH https://api.boltclaw.ai/v1/links/9d2b7c15-4e3a-4b88-9f21-0a6c5d3e7b90 \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
  "position": 1
}'
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/links/9d2b7c15-4e3a-4b88-9f21-0a6c5d3e7b90", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "position": 1
  }),
});

// 200
const data = await res.json();
```

### `DELETE /v1/links/{id}`

**Remove a link**

Scope `pages:write`. Another workspace's answers `404`.

Removes the link from its page. Ordinary page editing, so `pages:write` is enough.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Deleted` | Removed. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

## Shapes

The objects these operations send and answer.

### Deleted

| Field | Type |  |
|---|---|---|
| `deleted` | `true` | (**required**) |
| `id` | `uuid` | (**required**) |

### Link

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `label` | `string \| null` | (**required**) |
| `url` | `string \| null` | (**required**) |
| `icon` | `string \| null` | (**required**) |
| `position` | `integer` | (**required**) |
| `visible` | `boolean` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |

### LinkCreate

| Field | Type |  |
|---|---|---|
| `label` | `string \| null` | 120 characters by default; your organisation's number is `link_label_chars` in `GET /usage`. (max 120) |
| `url` | `string` | (**required**; min 1; max 2048) |
| `icon` | `string \| null` | (max 32) |
| `position` | `integer` | (>= 0; <= 10000) |
| `visible` | `boolean` |  |

### LinkList

| Field | Type |  |
|---|---|---|
| `data` | `Link[]` | At most 1,000 for a page by default: a safety ceiling the same on every plan. A page that had more when its ceiling was lowered keeps them, and every one is listed, up to 5,000, the most any page can ever hold. (**required**; <= 5000 items) |

### LinkUpdate

| Field | Type |  |
|---|---|---|
| `label` | `string \| null` | 120 characters by default; your organisation's number is `link_label_chars` in `GET /usage`. (max 120) |
| `url` | `string` | (min 1; max 2048) |
| `icon` | `string \| null` | (max 32) |
| `position` | `integer` | (>= 0; <= 10000) |
| `visible` | `boolean` |  |

<!-- https://boltclaw.ai/docs/api/links as markdown. Generated from openapi.yaml; API version 1.22.0. -->
<!-- Every /docs page answers as markdown: add .md to its path, or send Accept: text/markdown. -->
