---
title: "Endpoints"
description: "Complete reference of Forkline public API endpoints with request and response examples."
section: "reference"
url: "https://forkline.dev/docs/reference/endpoints"
markdown: "https://forkline.dev/docs/reference/endpoints.md"
source: "src/content/docs/reference/endpoints.mdx"
---

# Endpoints

This page lists all available endpoints on the Forkline public API, grouped by resource. Every mutating endpoint includes a request body example and response example.

## User

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/me` | `user:read` | Get your profile information |
| GET | `/v1/me/settings` | `user:read` | Get your user settings |
| PUT | `/v1/me/settings` | `user:write` | Update your user settings |

### GET `/v1/me`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/me
```

Response (`200 OK`):

```json
{
  "id": "u1234567-...",
  "email": "octocat@example.com",
  "name": "Octocat"
}
```

### GET `/v1/me/settings`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/me/settings
```

Response (`200 OK`):

```json
{
  "settings": {}
}
```

### PUT `/v1/me/settings`

Request body (`UpdateAllSettingsRequest`) — all fields are optional. Only provided fields are updated.

| Field | Type | Description |
|-------|------|-------------|
| `default_runner_size` | string | `basic`, `advanced`, or `pro` |
| `default_model` | string or null | Model ID or null to clear |
| `auto_fix_ci` | boolean | Enable/disable CI auto-fix |
| `auto_fix_max_attempts` | integer | 1–10 |
| `auto_fix_merge_conflicts` | boolean | Enable/disable merge conflict auto-fix |
| `auto_fix_merge_conflicts_max_attempts` | integer | 1–10 |
| `max_active_runners` | integer or null | Minimum 1, or null for no limit |
| `auto_pr_review` | boolean | Enable/disable auto PR review |
| `auto_pr_review_scope` | string | PR review scope |
| `auto_pr_review_action` | string | PR review action |
| `auto_pr_review_prompt` | string or null | Max 10,000 characters |

```bash
curl -X PUT \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"auto_fix_ci": true, "auto_fix_max_attempts": 5}' \
  https://api.forkline.dev/v1/me/settings
```

Response (`200 OK`): Returns the updated `CurrentUserResponse` object.

---

## Billing

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/billing/credits` | `billing:read` | Get your credit balance |

### GET `/v1/billing/credits`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/billing/credits
```

Response (`200 OK`):

```json
{
  "balance": 580,
  "subscription": {
    "plan_name": "basic",
    "status": "active",
    "period_credits_remaining": 480,
    "period_ends_at": "2026-10-01T00:00:00Z",
    "daily_credits": 480,
    "billing_period_end": "2026-10-01T00:00:00Z",
    "cancel_at": null
  },
  "purchased_credits": 100
}
```

`subscription` is `null` if the user has no active subscription.

---

## Repositories

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/repositories` | `repositories:read` | List your repositories |
| POST | `/v1/repositories` | `repositories:write` | Create a new repository |
| GET | `/v1/repositories/{id}` | `repositories:read` | Get repository details |
| PUT | `/v1/repositories/{id}` | `repositories:write` | Update repository settings |
| DELETE | `/v1/repositories/{id}` | `repositories:write` | Delete a repository (two-stage: soft-delete, then hard-delete) |
| POST | `/v1/repositories/{id}/restore` | `repositories:write` | Restore a soft-deleted repository |
| GET | `/v1/repositories/{id}/opencode-config` | `repositories:read` | Get repository OpenCode configuration |
| PUT | `/v1/repositories/{id}/opencode-config` | `repositories:write` | Update repository OpenCode configuration |
| DELETE | `/v1/repositories/{id}/opencode-config` | `repositories:write` | Clear repository OpenCode configuration |

### Query Parameters: GET `/v1/repositories`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `offset` | integer | `0` | Number of records to skip |
| `limit` | integer | `50` | Maximum records to return (max 100) |
| `q` | string | — | Search by repository name |
| `status` | string | `all` | Filter: `all`, `active`, or `disabled` |
| `sort` | string | `updated` | Sort by: `updated`, `name`, or `connector` |
| `include_deleted` | boolean | `false` | Include soft-deleted repositories |
| `active_only` | boolean | — | Only show active repositories |
| `connector_id` | UUID | — | Filter by connector |

### GET `/v1/repositories`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  "https://api.forkline.dev/v1/repositories?limit=10&status=active&sort=name"
```

Response (`200 OK`):

```json
{
  "repositories": [
    {
      "id": "a1b2c3d4-...",
      "user_id": "u1234567-...",
      "connector_id": "f0e1d2c3-...",
      "owner": "octocat",
      "name": "my-project",
      "repository_url": "https://github.com/octocat/my-project",
      "default_runner_size": "basic",
      "default_model": "anthropic/claude-sonnet-4-5",
      "trigger_policy": "collaborators",
      "allowed_users": [],
      "auto_fix_ci": true,
      "auto_fix_max_attempts": 3,
      "max_active_runners": 5,
      "created_at": "2026-01-15T10:30:00Z",
      "updated_at": "2026-09-01T08:00:00Z"
    }
  ],
  "total": 1,
  "offset": 0,
  "limit": 10,
  "has_more": false
}
```

### POST `/v1/repositories`

Request body (`CreateRepositoryRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `connector_id` | UUID | yes | The connector this repository belongs to |
| `owner` | string | yes | Repository owner (e.g. GitHub org or user) |
| `name` | string | yes | Repository name |
| `repository_url` | string | yes | Full URL to the repository |
| `default_runner_size` | string | no | Default runner size: `basic`, `advanced`, or `pro` |
| `default_model` | string | no | Default AI model (e.g. `anthropic/claude-sonnet-4-5`) |
| `trigger_policy` | string | no | Trigger policy: `owner_only`, `collaborators` (default), `allowlist` or `anyone` |
| `allowed_users` | string[] | no | Required when `trigger_policy` is `allowlist` |

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "connector_id": "f0e1d2c3-...",
    "owner": "octocat",
    "name": "my-project",
    "repository_url": "https://github.com/octocat/my-project",
    "default_runner_size": "basic",
    "trigger_policy": "collaborators"
  }' \
  https://api.forkline.dev/v1/repositories
```

Response (`201 Created`): Returns the full `Repository` object (see GET response above).

### PUT `/v1/repositories/{id}`

Request body (`UpdateRepositoryRequest`) — all fields are optional. Only provided fields are updated. To clear a nullable field, set it to `null`.

| Field | Type | Description |
|-------|------|-------------|
| `default_runner_size` | string or null | `basic`, `advanced`, `pro`, or null to clear |
| `default_model` | string or null | Model ID or null to clear |
| `trigger_policy` | string | `owner_only`, `collaborators`, `allowlist` or `anyone` |
| `allowed_users` | string[] | Required when switching to `allowlist` |
| `auto_fix_ci` | boolean or null | Enable/disable CI auto-fix |
| `auto_fix_max_attempts` | integer or null | 1–10 |
| `max_active_runners` | integer or null | Minimum 1 |
| `auto_fix_merge_conflicts` | boolean or null | Enable/disable merge conflict auto-fix |
| `auto_fix_merge_conflicts_max_attempts` | integer or null | 1–10 |
| `auto_pr_review` | boolean or null | Enable/disable auto PR review |
| `auto_pr_review_scope` | string or null | PR review scope |
| `auto_pr_review_action` | string or null | PR review action |
| `auto_pr_review_prompt` | string or null | Max 10,000 characters |
| `opencode_config` | object or null | OpenCode configuration object |

```bash
curl -X PUT \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"auto_fix_ci": true, "auto_fix_max_attempts": 5}' \
  https://api.forkline.dev/v1/repositories/<repository_id>
```

Response (`200 OK`): Returns the updated `Repository` object.

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

Two-stage deletion:
- **First call**: soft-deletes the repository (stops all runners, cancels tasks). The repository remains recoverable.
- **Second call** (on an already soft-deleted repository): hard-deletes permanently.

Response: `204 No Content`

### POST `/v1/repositories/{id}/restore`

Restores a soft-deleted repository. No request body.

Response (`200 OK`): Returns the restored `Repository` object.

### OpenCode Configuration

The OpenCode config endpoints manage per-repository OpenCode settings. The config is validated against a JSON schema. Certain keys are **blocked** and cannot be set by users: `permission`, `autoupdate`, `model`, `provider`. Maximum config size: 64 KB.

#### GET `/v1/repositories/{id}/opencode-config`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/repositories/<repository_id>/opencode-config
```

Response (`200 OK`):

```json
{
  "config": {
    "logLevel": "INFO",
    "instructions": ["Follow the project coding standards"],
    "mcp": {}
  }
}
```

`config` is `null` if no repository-level config has been set.

#### PUT `/v1/repositories/{id}/opencode-config`

Request body (`UpdateOpencodeConfigRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `config` | object | yes | OpenCode configuration object (max 64 KB) |

```bash
curl -X PUT \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"config": {"logLevel": "DEBUG", "instructions": ["Be concise"]}}' \
  https://api.forkline.dev/v1/repositories/<repository_id>/opencode-config
```

Response (`200 OK`):

```json
{
  "config": {
    "logLevel": "DEBUG",
    "instructions": ["Be concise"]
  }
}
```

#### DELETE `/v1/repositories/{id}/opencode-config`

Clears the repository-level OpenCode configuration. No request body.

Response: `204 No Content`

---

## Runners

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/runner-sizes` | `runners:read` | List available runner sizes |
| GET | `/v1/runners` | `runners:read` | List all runners |
| GET | `/v1/runners/ids` | `runners:read` | List all runner IDs |
| GET | `/v1/runners/{runner_id}` | `runners:read` | Get runner details |
| DELETE | `/v1/runners/{runner_id}` | `runners:write` | Stop and delete a runner |
| GET | `/v1/runners/{runner_id}/events` | `runners:read` | Get runner event history |
| POST | `/v1/runners/bulk-action` | `runners:write` | Perform bulk actions on runners |
| GET | `/v1/repositories/{id}/runners` | `runners:read` | List runners for a repository |
| POST | `/v1/repositories/{id}/runners` | `runners:write` | Start a new runner |
| GET | `/v1/runners/{runner_id}/settings` | `runners:read` | Get runner settings |
| PUT | `/v1/runners/{runner_id}/settings` | `runners:write` | Update runner settings |
| POST | `/v1/runners/{runner_id}/restart` | `runners:write` | Restart an active runner |
| POST | `/v1/runners/{runner_ref}/attach-grant` | `runners:attach` | Request a short-lived grant for attaching a local OpenCode session to this runner |

### Query Parameters: GET `/v1/runners`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | integer | `1` | Page number |
| `per_page` | integer | `25` | Items per page (max 100) |
| `repository_id` | UUID | — | Filter by repository |
| `status` | string | — | Filter by runner status |
| `connector_id` | UUID | — | Filter by connector |
| `source` | string | — | Filter by source: `manual` or `autonomous` |
| `sort_by` | string | — | Sort field |
| `sort_order` | string | — | Sort direction |

### Query Parameters: GET `/v1/repositories/{id}/runners`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | integer | `1` | Page number |
| `per_page` | integer | `25` | Items per page (max 100) |
| `sort_by` | string | — | Sort field |
| `sort_order` | string | — | Sort direction |

### GET `/v1/repositories/{id}/runners`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  "https://api.forkline.dev/v1/repositories/<repository_id>/runners?page=1&per_page=25"
```

Response (`200 OK`): the same `{ "runners": [...], "total": <int> }` envelope as `GET /v1/runners`, scoped to the repository in the path.

### GET `/v1/runners/ids`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/runners/ids
```

Response (`200 OK`):

```json
{
  "runner_ids": ["r4n5n6r7-...", "r8s9t0u1-..."]
}
```

Accepts the same query parameters as `GET /v1/runners` for filtering.

### GET `/v1/runner-sizes`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/runner-sizes
```

Response (`200 OK`):

```json
{
  "sizes": [
    {"id": "basic", "name": "Basic", "cpu": 2, "ram": "4GB", "multiplier": 1},
    {"id": "advanced", "name": "Advanced", "cpu": 4, "ram": "8GB", "multiplier": 2},
    {"id": "pro", "name": "Pro", "cpu": 8, "ram": "16GB", "multiplier": 4}
  ]
}
```

### GET `/v1/runners`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  "https://api.forkline.dev/v1/runners?page=1&per_page=10"
```

Response (`200 OK`):

```json
{
  "runners": [
    {
      "id": "r4n5n6r7-...",
      "short": "r4n5",
      "repository_id": "a1b2c3d4-...",
      "name": "api-test-runner",
      "status": "running",
      "source": "manual",
      "size": "basic",
      "model": "anthropic/claude-sonnet-4-5",
      "created_at": "2026-09-18T14:20:00Z",
      "started_at": "2026-09-18T14:20:30Z",
      "stopped_at": null
    }
  ],
  "total": 1
}
```

### POST `/v1/repositories/{id}/runners`

Request body (`StartRunnerRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Runner name |
| `size` | string | no | Runner size: `basic`, `advanced`, or `pro`. Falls back to repository default, then user default |
| `model` | string | no | AI model (e.g. `anthropic/claude-sonnet-4-5`). Falls back to repository default |
| `initial_prompt` | string | no | If provided, starts an autonomous runner that executes this prompt |

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-runner", "size": "advanced", "model": "anthropic/claude-sonnet-4-5"}' \
  https://api.forkline.dev/v1/repositories/<repository_id>/runners
```

Response (`201 Created`): Returns the `Runner` object.

> **Note**: Starting a runner requires sufficient credits. If credits are insufficient, the API returns `402 INSUFFICIENT_CREDITS`.

### GET `/v1/runners/{runner_id}/events`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/runners/<runner_id>/events
```

Response (`200 OK`):

```json
{
  "events": [
    {
      "id": "evt-...",
      "runner_id": "r4n5n6r7-...",
      "event_type": "started",
      "previous_status": "starting",
      "new_status": "running",
      "details": {},
      "created_at": "2026-09-18T14:20:30Z"
    }
  ]
}
```

### DELETE `/v1/runners/{runner_id}`

Stops an active runner or permanently deletes a terminal runner. No request body.

Response: `204 No Content`

### POST `/v1/runners/bulk-action`

Request body (`BulkRunnerActionRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `action` | string | yes | `stop` or `delete` |
| `runner_ids` | UUID[] | yes | Runner IDs to act on (max 100) |

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action": "stop", "runner_ids": ["r4n5n6r7-...", "r8s9t0u1-..."]}' \
  https://api.forkline.dev/v1/runners/bulk-action
```

Response (`200 OK`) (`BulkRunnerActionResponse`):

```json
{
  "succeeded": 2,
  "failed": 0,
  "errors": []
}
```

- `stop`: Stops all active runners. Non-active runners are reported in `errors`.
- `delete`: Permanently deletes runners in a terminal state. Non-terminal runners are skipped and reported in `errors`.

### GET `/v1/runners/{runner_id}/settings`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/runners/<runner_id>/settings
```

Response (`200 OK`):

```json
{
  "runner_id": "r4n5n6r7-...",
  "settings": {},
  "updated_at": "2026-09-18T14:20:00Z"
}
```

### PUT `/v1/runners/{runner_id}/settings`

Request body (`UpdateSettingsRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `settings` | object | yes | Arbitrary JSON settings object |

```bash
curl -X PUT \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"settings": {"logLevel": "debug"}}' \
  https://api.forkline.dev/v1/runners/<runner_id>/settings
```

Response (`200 OK`): Returns the updated `RunnerSettingsResponse`.

### POST `/v1/runners/{runner_id}/restart`

Restarts an active runner. No request body. Returns `409` if the runner is not in an active state.

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/runners/<runner_id>/restart
```

Response (`200 OK`): Returns the `Runner` object.

---

## Models

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/models` | `models:read` | List available AI models (filtered by your configured providers) |
| GET | `/v1/models/providers` | `models:read` | List all model providers |
| GET | `/v1/providers/{provider_id}/icon` | `models:read` | Get provider icon (returns SVG) |

### GET `/v1/models`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/models
```

Response (`200 OK`):

```json
{
  "providers": [
    {
      "id": "opencode",
      "name": "OpenCode Zen",
      "models": [
        {
          "id": "free-model",
          "name": "Free Model",
          "tool_call": true,
          "reasoning": false,
          "is_free": true
        }
      ]
    },
    {
      "id": "anthropic",
      "name": "Anthropic",
      "models": [
        {
          "id": "claude-sonnet-4-5",
          "name": "Claude Sonnet 4.5",
          "tool_call": true,
          "reasoning": false,
          "is_free": false
        }
      ]
    }
  ]
}
```

### GET `/v1/models/providers`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/models/providers
```

Response (`200 OK`):

```json
{
  "providers": [
    {"id": "anthropic", "name": "Anthropic"},
    {"id": "openai", "name": "OpenAI"},
    {"id": "google", "name": "Google"}
  ]
}
```

### GET `/v1/providers/{provider_id}/icon`

Returns an SVG image with `Content-Type: image/svg+xml` and `Cache-Control: public, max-age=86400`. Returns `404` if the icon is not found.

---

## Analytics

All analytics endpoints accept optional date range query parameters.

### Query Parameters (all analytics endpoints)

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `start` | date (YYYY-MM-DD) | 30 days ago | Start of the date range |
| `end` | date (YYYY-MM-DD) | today | End of the date range |

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/analytics/overview` | `analytics:read` | Get analytics overview |
| GET | `/v1/analytics/providers` | `analytics:read` | Get analytics by provider |
| GET | `/v1/analytics/models` | `analytics:read` | Get analytics by model |
| GET | `/v1/analytics/repositories` | `analytics:read` | Get analytics by repository |
| GET | `/v1/analytics/history` | `analytics:read` | Get historical analytics data |

### GET `/v1/analytics/overview`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  "https://api.forkline.dev/v1/analytics/overview?start=2026-09-01&end=2026-09-18"
```

Response (`200 OK`):

```json
{
  "input_tokens": 1250000,
  "output_tokens": 340000,
  "reasoning_tokens": 85000,
  "cache_read_tokens": 500000,
  "cache_write_tokens": 120000,
  "total_tokens": 2295000,
  "runner_hours": 48.5,
  "sessions_count": 120,
  "active_provider_count": 3,
  "active_model_count": 5,
  "active_repository_count": 2
}
```

### GET `/v1/analytics/providers`

Response (`200 OK`): Array of `UsageBreakdownResponse` objects:

```json
[
  {
    "key": "anthropic",
    "provider_id": "anthropic",
    "input_tokens": 800000,
    "output_tokens": 200000,
    "reasoning_tokens": 50000,
    "cache_read_tokens": 300000,
    "cache_write_tokens": 80000,
    "total_tokens": 1430000,
    "runner_hours": 30.0,
    "sessions_count": 80,
    "cache_ratio": 20.98,
    "share_percent": 62.31,
    "repository_count": 2,
    "model_count": 3
  }
]
```

### GET `/v1/analytics/history`

Response (`200 OK`):

```json
{
  "points": [
    {
      "day": "2026-09-18",
      "input_tokens": 50000,
      "output_tokens": 12000,
      "reasoning_tokens": 3000,
      "cache_read_tokens": 20000,
      "cache_write_tokens": 5000,
      "total_tokens": 90000,
      "runner_hours": 2.5,
      "sessions_count": 8
    }
  ]
}
```

---

## Routines

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/routines` | `routines:read` | List routines |
| POST | `/v1/routines` | `routines:write` | Create a new routine |
| GET | `/v1/routines/{id}` | `routines:read` | Get routine details |
| PUT | `/v1/routines/{id}` | `routines:write` | Update a routine |
| DELETE | `/v1/routines/{id}` | `routines:write` | Delete a routine |
| GET | `/v1/routines/{id}/runs` | `routines:read` | List the runs of a routine with the reason for each outcome |
| GET | `/v1/routines/events` | `routines:read` | List recent events from the last 7 days with the routines that reacted to each |
| GET | `/v1/routines/events/{event_id}` | `routines:read` | Get one recent event with its reactions and the filters a new routine could use |
| POST | `/v1/routines/validate` | `routines:read` | Check a routine spec without saving it: every problem with a JSON pointer, the repositories it selects and the defaults it gets (120 per minute) |
| POST | `/v1/routines/test` | `routines:read` | Dry-run a routine spec against a recent event, a pasted webhook payload, a repository or a webhook body: every stage in order, the predicted outcome and the rendered prompt. Starts nothing (30 per minute, shared with dry runs) |
| POST | `/v1/routines/{id}/test` | `routines:read` | The same dry run for the saved spec of a routine; the request has no `spec` (30 per minute, shared with dry runs) |
| POST | `/v1/routines/backtest` | `routines:read` | Replay a routine spec over your stored events of the last 1 to 7 days: what it would have run, what each filter stopped, a reason per event and a weekly cost estimate. Starts nothing (60 per minute) |
| POST | `/v1/routines/{id}/backtest` | `routines:read` | The same replay for the saved spec of a routine; the request has no `spec` (60 per minute) |
| POST | `/v1/routines/drafts/from-event` | `routines:read` | Draft a valid routine spec from a recent event: the facts you keep become filters (each value says where it came from), a starter prompt, a unique name and up to two suggestions to widen the selection to sibling repositories with a 7-day replay (`widen: true` selects the event's repository and the first pattern). Saves nothing (30 per minute) |
| GET | `/v1/routines/{id}/tasks` | `routines:read` | Get routine task history |
| GET | `/v1/routines/{id}/tasks/{task_id}` | `routines:read` | Get task detail |
| POST | `/v1/routines/{id}/publish` | `routines:write` | Publish a draft routine |
| POST | `/v1/routines/{id}/disable` | `routines:write` | Disable a published routine |
| POST | `/v1/routines/{id}/enable` | `routines:write` | Enable a disabled routine |
| POST | `/v1/routines/{id}/run` | `routines:execute` | Run now: start a routine yourself, or preview it with `dry_run`. Works for webhook, schedule and event routines (10 real runs per minute) |
| POST | `/v1/routines/{id}/webhook/rotate-secret` | `routines:write` | Replace the generated secret of a custom webhook routine |
| PUT | `/v1/routines/{id}/webhook/secret` | `routines:write` | Paste the vendor's secret for a `github` or `sentry` webhook routine |

### Routine Lifecycle

Routines follow a status lifecycle:

```
draft → published → disabled
         ↑              |
         └──────────────┘ (re-publish not supported; create a new routine)
```

- **draft**: The routine is being edited. It cannot be run for real (a dry run works).
- **published**: The routine is active. Event and schedule routines trigger automatically. Custom webhook routines run when another system calls their URL with the routine's secret. Any published routine can also be started with Run now.
- **disabled**: The routine is paused. It will not trigger on events. Run now still works.

**Running a routine now**: `POST /v1/routines/{id}/run` starts a published or disabled routine of any trigger. The response returns `task_ids` and `session_urls` for the created tasks.

**Polling tasks**: After a run, use `GET /v1/routines/{id}/tasks` to check task progress. Each task has a `status` field (`pending`, `running`, `completed`, `failed`) and an optional `error_message`.

### GET `/v1/routines`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/routines
```

Response (`200 OK`):

```json
{
  "routines": [
    {
      "id": "rn-1234-...",
      "name": "auto-deploy",
      "description": "Deploy on webhook",
      "spec": {
        "on": {"webhook": {"auth": {"type": "hmac", "preset": "forkline"}}},
        "repositories": {"include": [{"name": "acme/api"}]},
        "agent": {"prompt": "Deploy {{ repository.fullName }}"}
      },
      "status": "published",
      "webhook_secret_configured": true,
      "webhook_secret_origin": "generated",
      "webhook_secret_updated_at": "2026-09-01T10:00:00Z",
      "created_at": "2026-09-01T10:00:00Z",
      "updated_at": "2026-09-15T12:00:00Z",
      "published_at": "2026-09-02T08:00:00Z"
    }
  ]
}
```

> **Note**: The signing secret of a custom webhook routine is never part of `spec`. Responses show `webhook_secret_configured`, `webhook_secret_origin` (`generated` by Forkline, or `provided` by you) and `webhook_secret_updated_at`; a generated secret itself is returned once, by the request that creates or rotates it. A provided secret is never returned.

### POST `/v1/routines`

Request body (`CreateRoutineRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Alphanumeric, hyphens, underscores only. Max 100 chars |
| `description` | string | no | Human-readable description |
| `spec` | object | yes | Routine specification (see below) |

The `spec` object is the routine definition (version 2). Unknown fields are rejected.

| Field | Required | Description |
|-------|----------|-------------|
| `on` | yes | Exactly one of `events` (1 to 8 of `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed`, `issue.opened`, `comment.created`, `ci.failed`, `push`), `schedule` (`cron` with optional `timezone`, or `at`) and `webhook` (`{}` for a bearer token, or `auth` `{type: token, header}` / `{type: hmac, preset: forkline\|github\|sentry}`, `idempotencyHeader`, `example`) |
| `repositories.include` | yes | `"all"` or 1 to 50 selectors: `{"id": "<uuid>"}` or `{"name": "owner/glob"}` |
| `repositories.exclude` | no | Up to 50 selectors |
| `filters` | no | `branches`, `paths`, `labels`, `authors`, `draft`, `fork`, `bots`, `self`, `command` and a typed CEL `when`; each filter must apply to every listed event |
| `agent.prompt` | yes | Prompt template, up to 32 KiB, checked against the variables of the events |
| `agent.model`, `agent.runnerSize`, `agent.ref`, `agent.timeoutMinutes` | no | Model, `basic`/`advanced`/`pro`, git ref template, 1 to 240 minutes (default 30) |
| `limits.maxRunsPerPr` | no | 1 to 50, default 3 |

The full field reference (template variables, `when` rules) belongs to the Routines guide, which is not published yet. A schedule or webhook routine must select between 1 and 200 repositories.

An invalid spec returns `422 Unprocessable Entity` with one entry per problem, each with a JSON pointer:

```json
{
  "error": "Invalid routine spec: /filters/labels/inlcude: Unknown field `inlcude`. Allowed here: `include`, `exclude`. Did you mean `include`?",
  "code": "UNPROCESSABLE_ENTITY",
  "errors": [
    {"pointer": "/filters/labels/inlcude", "message": "Unknown field `inlcude`. Allowed here: `include`, `exclude`. Did you mean `include`?"}
  ]
}
```

For an `on.webhook` routine with a generated secret (a token, or `preset: forkline`) the response to this request includes `webhook_secret` once. For a vendor preset (`github`, `sentry`) there is none: set it with `PUT /v1/routines/{id}/webhook/secret` before you publish. Store it: it cannot be read again.

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "auto-deploy",
    "description": "Deploy on webhook",
    "spec": {
      "on": {"webhook": {}},
      "repositories": {"include": [{"name": "acme/api"}]},
      "agent": {"prompt": "Deploy {{ repository.fullName }} to staging: {{ payload.text }}"}
    }
  }' \
  https://api.forkline.dev/v1/routines
```

Response (`200 OK`): Returns the `RoutineResponse` object.

### PUT `/v1/routines/{id}`

Request body (`UpdateRoutineRequest`) — all fields optional:

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | New name |
| `description` | string | New description |
| `spec` | object | New routine spec (full replacement) |

```bash
curl -X PUT \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"description": "Updated description"}' \
  https://api.forkline.dev/v1/routines/<routine_id>
```

Response (`200 OK`): Returns the updated `RoutineResponse` object.

### POST `/v1/routines/{id}/publish`

Transitions a `draft` routine to `published`. No request body. The routine spec is validated again before publishing.

Response (`200 OK`): Returns the `RoutineResponse` with `status: "published"`.

Returns `400` if the routine is not a draft, or is an `on.webhook` routine without a signing secret. Returns `409` when you already have the maximum number of published routines (25). Returns `422` if the spec no longer validates (for example when a repository it selects is gone), with the same `errors` list as on create.

### POST `/v1/routines/{id}/disable`

Transitions a `published` routine to `disabled`. No request body.

Response (`200 OK`): Returns the `RoutineResponse` with `status: "disabled"`.

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

Permanently deletes a routine. No request body.

Response: `204 No Content`

### POST `/v1/routines/{id}/run`

Run now: starts a routine yourself, or previews what it would start. Requires `routines:execute` and, like every routine endpoint, the routine's owner. Connect another system with the webhook URL instead; the same limits apply to both.

Request body (`RunRoutineRequest`), all fields optional:

| Field | Type | Description |
|-------|------|-------------|
| `dry_run` | boolean | `true` evaluates and renders only: nothing is written and no run starts. Works on a draft. Default `false` |
| `repositories` | uuid[] | Webhook and schedule routines: a subset of the routine's selected repositories. Default: all of them. Not allowed for event routines |
| `payload` | any JSON | Webhook routines only: the body a delivery would carry, at most 5 MiB, like a webhook body. Default `{}`. Not allowed for other triggers |
| `event_id` | uuid | Event routines only, and required for them: a recent forge event to replay (the id from `GET /v1/routines/events`) |

`event_id` cannot be combined with `repositories` or `payload`. An optional `Idempotency-Key` header makes a repeated request start nothing new.

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"dry_run": true, "payload": {"level": "error", "title": "High error rate"}}' \
  https://api.forkline.dev/v1/routines/<routine_id>/run
```

Response (`200 OK`) (`RunRoutineResponse`) of a real run:

```json
{
  "dry_run": false,
  "event_id": "0000000e-0000-4000-8000-000000000001",
  "targets": [
    {
      "repository": {"id": "0000000a-0000-4000-8000-000000000001", "full_name": "acme/api"},
      "outcome": "task_created",
      "task_id": "t1a2b3c4-...",
      "session_url": "/sessions/t1a2b3c4-...",
      "reason": "Started a run for `acme/api`."
    }
  ],
  "estimate": {"runs": 1, "credits_per_run_max": 30, "credits_max": 30},
  "task_ids": ["t1a2b3c4-..."],
  "session_urls": ["/sessions/t1a2b3c4-..."]
}
```

A dry run returns the same envelope with `dry_run: true`, no `event_id` or tasks, and per target a `predicted_outcome`, `decisive_stage`, `reason`, `rendered_prompt`, `rendered_ref` and the `stages`. `not_evaluated` lists the checks that only happen at run time. `estimate` is the number of runs that would start and the most credits they can use. A dry run of an event replay also lists `not_stored` on a target: the fields the prompt, `agent.ref`, `when` or a `paths` filter read that Forkline does not keep. When it is not empty, `estimate.runs` is 0 and a real run is refused with `422` `event_fields_not_stored`. The top-level `reason` and `event_id` are left out when there is none.

`event_id` is the event record of this run; read it with `GET /v1/routines/events/{event_id}`. Its source is `manual`, and the run history shows who started it. `task_ids` is empty when nothing started, for example when a run for the same group is active: the target's `outcome` is then `skipped_active`. Run now **always** skips while a run is active, whatever the routine's `onConflict` says.

Errors:

| Status | When |
|--------|------|
| `400` | Bad JSON, an unknown field in the body (`Invalid request body: unknown field ...`, so a typo cannot start a run), a field that does not fit the routine's trigger (`payload` on a schedule routine, `payload` or `repositories` on an event routine: `` `payload` and `repositories` are not allowed: this routine runs on forge events; send an `event_id` ``, `event_id` on a webhook or schedule routine, no `event_id` on an event routine), `event_id` with `repositories` or `payload`, an empty `repositories`, or an event that is not a forge event |
| `404` | The routine or the event does not exist |
| `409` | A real run of a draft routine (message starts with `ROUTINE_DRAFT:`) |
| `413` | The request is over 6 MiB, or `payload` is over 5 MiB |
| `422` | `repository_not_selected`, `filters_not_matched` (nothing starts; one issue per blocked repository), `event_fields_not_stored`, `event_repository_unavailable`, `fan_out_too_large`, or the usual `Invalid routine spec` issues when the saved spec no longer validates |
| `429` | More than 10 real runs a minute (dry runs count toward the 30 a minute shared with `test`), or Forkline is busy. A `Retry-After` header says when to retry. Nothing was started. A request refused before it is evaluated does not count toward the limits; a real run that its filters refuse does. `forkline routine run` exits with code 9 |

```json
{
  "error": "Forkline is busy; retry shortly",
  "code": "TOO_MANY_REQUESTS",
  "retry_after_secs": 5
}
```

The routine's `concurrency` settings apply as for any other trigger, except that Run now never replaces a running run.

### POST `/v1/routines/{id}/webhook/rotate-secret`

Replaces the signing secret of an `on.webhook` routine. No request body. Requires `routines:write`.

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/routines/<routine_id>/webhook/rotate-secret
```

Response (`200 OK`): the `RoutineResponse` with a new `webhook_secret`, shown only in this response. The previous secret stops working immediately. Returns `400` if the routine has no `on.webhook` trigger, or if its secret was pasted by you (`github`, `sentry`): rotate that one at the sender.

### PUT `/v1/routines/{id}/webhook/secret`

Stores the signing secret that a vendor gave you, for an `on.webhook` routine with `auth.preset` `github` or `sentry`. Requires `routines:write`.

```bash
curl -X PUT \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"secret": "<the vendor secret>"}' \
  https://api.forkline.dev/v1/routines/<routine_id>/webhook/secret
```

The secret is 16 to 512 bytes; surrounding whitespace is trimmed. Response (`200 OK`): the `RoutineResponse` with `webhook_secret_configured: true`, `webhook_secret_origin: "provided"` and `webhook_secret_updated_at`. The secret is never returned. Returns `400` if the routine has no `on.webhook` trigger, if its secret is generated by Forkline (a token or `preset: forkline`; use rotate instead), or if the secret is too short or too long. Switching between a token and `forkline` keeps the secret. Moving to `github` or `sentry`, or between them, clears it, so paste the new one. Moving from a pasted secret to a token or `forkline` generates one, shown once. A published routine keeps answering `401 Missing signature` until you paste it.

### GET `/v1/routines/{id}/tasks`

Returns the 50 most recent tasks created by this routine.

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/routines/<routine_id>/tasks
```

Response (`200 OK`) (`ListRoutineTasksResponse`):

```json
{
  "tasks": [
    {
      "id": "t1a2b3c4-...",
      "status": "completed",
      "task_type": "generic",
      "repository_url": "https://github.com/octocat/my-project",
      "result_type": "pull_request",
      "created_at": "2026-09-18T14:25:00Z",
      "started_at": "2026-09-18T14:26:00Z",
      "completed_at": "2026-09-18T14:35:00Z",
      "error_message": null
    }
  ]
}
```

Task statuses: `pending`, `running`, `completed`, `failed`. Poll this endpoint to track progress after a routine run.

### GET `/v1/routines/{id}/tasks/{task_id}`

Returns full detail for a single task, including prompt, result data, and platform result.

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/routines/<routine_id>/tasks/<task_id>
```

Response (`200 OK`) (`TaskDetailResponse`):

```json
{
  "id": "t1a2b3c4-...",
  "status": "completed",
  "task_type": "code_change",
  "repository_url": "https://github.com/octocat/my-project",
  "prompt": "Fix the login bug...",
  "context": { "issue_number": 42 },
  "result_type": "pull_request",
  "result_data": { "title": "Fix login", "body": "...", "head": "fix/login", "base": "main" },
  "platform_result": { "url": "https://github.com/octocat/my-project/pull/99", "number": 99 },
  "error_message": null,
  "runner_id": "r1a2b3c4-...",
  "attempt_count": 1,
  "created_at": "2026-09-18T14:25:00Z",
  "started_at": "2026-09-18T14:26:00Z",
  "completed_at": "2026-09-18T14:35:00Z"
}
```

Returns `404` if the routine or task does not exist, or if the task does not belong to the routine.

---

## Connectors

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/connectors` | `connectors:read` | List your git connectors |

### GET `/v1/connectors`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/connectors
```

Response (`200 OK`):

```json
{
  "connectors": [
    {
      "id": "f0e1d2c3-...",
      "provider": "github",
      "name": "octocat",
      "created_at": "2026-01-10T08:00:00Z"
    }
  ]
}
```

---

## API Tokens

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/me/api-tokens` | `tokens:manage` | List your API tokens |
| POST | `/v1/me/api-tokens` | `tokens:manage` | Create a new API token |
| DELETE | `/v1/me/api-tokens/{token_id}` | `tokens:manage` | Revoke an API token |

### GET `/v1/me/api-tokens`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/me/api-tokens
```

Response (`200 OK`):

```json
{
  "api_tokens": [
    {
      "id": "t1a2b3c4-...",
      "name": "CI Pipeline",
      "scopes": ["runners:read", "runners:write"],
      "created_at": "2026-09-01T10:00:00Z",
      "expires_at": "2026-12-01T10:00:00Z",
      "last_used_at": "2026-09-18T14:00:00Z"
    }
  ]
}
```

### POST `/v1/me/api-tokens`

Request body (`CreateApiTokenRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Descriptive name for the token |
| `scopes` | string[] | yes | Scopes to grant (see [Authentication](https://forkline.dev/docs/reference/authentication#scopes)) |
| `expires_in_days` | integer | no | Days until expiration (default 90) |
| `never_expires` | boolean | no | If `true`, the token does not expire |

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "CI Pipeline", "scopes": ["runners:read", "runners:write"]}' \
  https://api.forkline.dev/v1/me/api-tokens
```

Response (`201 Created`):

```json
{
  "token": "fl_pat_v1_...",
  "api_token": {
    "id": "t1a2b3c4-...",
    "name": "CI Pipeline",
    "scopes": ["runners:read", "runners:write"],
    "created_at": "2026-09-18T14:20:00Z",
    "expires_at": "2026-12-17T14:20:00Z",
    "last_used_at": null
  }
}
```

> **Warning**: The `token` field is shown **only once** at creation. Store it securely.

### DELETE `/v1/me/api-tokens/{token_id}`

Revokes an API token. No request body.

Response: `204 No Content`

---

## Repository Secrets

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/repositories/{id}/secrets` | `secrets:manage` | List repository secrets |
| POST | `/v1/repositories/{id}/secrets` | `secrets:manage` | Create a repository secret |
| PUT | `/v1/repositories/{id}/secrets/{name}` | `secrets:manage` | Update a repository secret |
| DELETE | `/v1/repositories/{id}/secrets/{name}` | `secrets:manage` | Delete a repository secret |

### GET `/v1/repositories/{id}/secrets`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/repositories/<repository_id>/secrets
```

Response (`200 OK`):

```json
{
  "secrets": [
    {
      "id": "s1a2b3c4-...",
      "repository_id": "a1b2c3d4-...",
      "name": "DEPLOY_KEY",
      "created_at": "2026-09-01T10:00:00Z",
      "updated_at": "2026-09-15T12:00:00Z"
    }
  ]
}
```

> **Note**: Secret values are never returned in API responses.

### POST `/v1/repositories/{id}/secrets`

Request body (`CreateSecretRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Secret name (unique per repository) |
| `value` | string | yes | Secret value |

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "DEPLOY_KEY", "value": "secret-value"}' \
  https://api.forkline.dev/v1/repositories/<repository_id>/secrets
```

Response (`201 Created`): Returns the `RepositorySecret` metadata (without the value).

### PUT `/v1/repositories/{id}/secrets/{name}`

Request body (`UpdateSecretRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `value` | string | yes | New secret value |

```bash
curl -X PUT \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value": "new-secret-value"}' \
  https://api.forkline.dev/v1/repositories/<repository_id>/secrets/DEPLOY_KEY
```

Response: `200 OK`

### DELETE `/v1/repositories/{id}/secrets/{name}`

Deletes a repository secret. No request body.

Response: `204 No Content`

---

## Account Secrets

Account credentials that MCP servers can bind to. Values are write-only: they are stored in Vault and no response ever returns them.

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/me/secrets` | `secrets:manage` | List account credentials (metadata only) |
| POST | `/v1/me/secrets` | `secrets:manage` | Create a credential |
| PUT | `/v1/me/secrets/{name}` | `secrets:manage` | Create or rotate a credential |
| PATCH | `/v1/me/secrets/{name}` | `secrets:manage` | Update a credential's description |
| DELETE | `/v1/me/secrets/{name}` | `secrets:manage` | Delete a credential |

Names must match `^[A-Z_][A-Z0-9_]*$` (maximum 255 characters) — the same grammar the CLI enforces, so a typo fails at the request instead of when a server is enabled.

### GET `/v1/me/secrets`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/me/secrets
```

Response (`200 OK`):

```json
{
  "secrets": [
    {
      "id": "9f1c2a34-...",
      "user_id": "a1b2c3d4-...",
      "name": "GRAFANA_TOKEN",
      "description": "Read-only Grafana token",
      "rotated_at": "2026-10-06T08:12:00Z",
      "created_at": "2026-09-20T10:00:00Z",
      "updated_at": "2026-10-06T08:12:00Z",
      "used_by": [ { "server_id": "c3d4e5f6-...", "name": "Grafana" } ]
    }
  ]
}
```

### POST `/v1/me/secrets`

Request body (`CreateSecretRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Credential name (`^[A-Z_][A-Z0-9_]*$`, max 255 characters) |
| `value` | string | yes | Secret value; written to Vault, never returned |

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "GRAFANA_TOKEN", "value": "glsa_xxx"}' \
  https://api.forkline.dev/v1/me/secrets
```

Response (`201 Created`): the `AccountSecret` metadata. Returns `409` if the name already exists. This endpoint takes no description — use `PUT` or `PATCH` for that.

### PUT `/v1/me/secrets/{name}`

Request body (`UpsertAccountSecretRequest`): `value` (required) and `description` (optional). Creates the credential if it does not exist, otherwise rotates it and stamps `rotated_at`.

```bash
curl -X PUT \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value": "glsa_new"}' \
  https://api.forkline.dev/v1/me/secrets/GRAFANA_TOKEN
```

Response (`200 OK`): the updated `AccountSecret`.

### PATCH `/v1/me/secrets/{name}`

Request body (`UpdateAccountSecretDescriptionRequest`): `{ "description": "…" }`. Only the description changes; the stored value is untouched. Send an empty string to clear it.

### DELETE `/v1/me/secrets/{name}`

Response (`204 No Content`). Servers that bound the credential are not modified; they read `needs_setup` until it is provided again.

## MCP Catalog

The catalog is the set of MCP servers Forkline knows about, each with one or more compiled revisions carrying a frozen install plan.

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/mcp-catalog` | `mcp:read` | Browse catalog entries |
| GET | `/v1/mcp-catalog/{id}` | `mcp:read` | One entry with its revisions |

`{id}` accepts either the source-qualified `external_id` (`mcp-registry/io.github.context7/context7`) or the short `slug` (`context7`).

### Query Parameters: GET `/v1/mcp-catalog`

| Parameter | Type | Description |
|-----------|------|-------------|
| `q` | string | Substring search over title, slug, and canonical name. A search also returns servers that are not available on Forkline, with `installable: false` and an `unavailable_reason` |
| `after` | string | Cursor: the `next_cursor` of the previous page, passed back unchanged |
| `limit` | integer | Page size, default 50, clamped to 1..500 |

The list is two installable groups, Verified first and then Unverified (curated servers first, then by title). It has no filters. Each entry has a `tier` of `verified`, `unverified`, or `unsupported`; `verified_versions` lists the versions that are Verified, and `latest_version` is the newest installable version.

### GET `/v1/mcp-catalog`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  "https://api.forkline.dev/v1/mcp-catalog?q=grafana&limit=20"
```

Response (`200 OK`):

```json
{
  "entries": [
    {
      "id": "4d7e1b22-...",
      "external_id": "mcp-registry/io.github.context7/context7",
      "source": "official_registry",
      "canonical_name": "io.github.context7/context7",
      "slug": "context7",
      "title": "Context7",
      "description": "Up-to-date documentation for any library",
      "category": "Developer Tools",
      "tags": ["docs", "libraries"],
      "repository_url": "https://github.com/upstash/context7",
      "website_url": "https://context7.com",
      "license": "MIT",
      "transport": "local",
      "version": "3.2.1",
      "tier": "tested",
      "warnings": [],
      "credential_requirements": [
        {
          "name": "CTX7_KEY",
          "description": "API key for higher rate limits",
          "required": false,
          "target": "environment",
          "is_secret": true,
          "format": null,
          "default_value": null
        }
      ],
      "plan_hash": "sha256:6f0a…",
      "compiler_version": "plan-compiler/1",
      "recipe_version": "1",
      "latest_version": "3.3.0",
      "status": "active",
      "curated": true,
      "hidden": false,
      "default_revision_id": "8ab4…",
      "created_at": "2026-08-01T00:00:00Z",
      "updated_at": "2026-10-05T04:20:00Z"
    }
  ],
  "next_cursor": "mcp-registry/io.github.context7/context7",
  "snapshot_stale_at": "2026-10-04T04:10:00Z"
}
```

`next_cursor` is `null` on the last page; pass it back as `after` to continue. A `tier` of `unsupported` carries a mandatory reason code in `unsupported_reason` (`oci`, `cargo`, `nuget`, `mcpb`, `oauth-remote`, `egress`, or `no-plan`) plus `unsupported_detail` and usually a `remediation`. `snapshot_stale_at` is set when the service is serving a last-known-good snapshot because the upstream registry could not be refreshed.

### GET `/v1/mcp-catalog/{id}`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/mcp-catalog/context7
```

Response (`200 OK`): `{ "entry": { … }, "revisions": [ … ] }`, where each revision carries its own `version`, `tier`, `plan`, `plan_hash`, `credential_requirements`, `warnings`, `evidence` (the harness record behind a `tested` claim), and `superseded_by` / `superseded_at` when a newer revision replaced it.

## MCP Servers

An MCP server is a user-owned tool server your runners load: installed from the catalog (pinned to one revision) or supplied as custom configuration. Every handler is scoped to the authenticated user: another user's server is a `404`, never a `200`. A server is switched on and off with `enabled`; there is no publish step.

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/mcp-servers` | `mcp:read` | List your servers with their status |
| POST | `/v1/mcp-servers` | `mcp:write` | Create a server |
| POST | `/v1/mcp-servers/parse` | `mcp:write` | Turn pasted JSON (Claude Code, Cursor, VS Code, OpenCode) into draft servers; stores nothing |
| GET | `/v1/mcp-servers/{id}` | `mcp:read` | Get one server |
| PATCH | `/v1/mcp-servers/{id}` | `mcp:write` | Update a server (one transaction) |
| DELETE | `/v1/mcp-servers/{id}` | `mcp:write` | Delete a server |
| POST | `/v1/mcp-servers/{id}/upgrade` | `mcp:write` | Move a catalog server to another version |
| GET | `/v1/mcp-servers/{id}/upgrade-preview` | `mcp:read` | Current versus target version (`version` query parameter) |
| GET | `/v1/mcp-servers/{id}/repositories` | `mcp:read` + `repositories:read` | The repositories the server reaches, with its state on each (paged) |
| GET | `/v1/repositories/{id}/mcp-servers` | `mcp:read` + `repositories:read` | The servers that apply to one repository |

The status tells you whether a server runs, never how it was applied. `status.state` is one of `working`, `failing`, `needs_setup`, `unavailable` ("Not running": a problem on our side), `not_used_yet` and `off`.

### POST `/v1/mcp-servers`

Request body (`CreateMcpServerRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Display name |
| `slug` | string | no | The Server ID, unique per user (derived from the name when omitted); `409` when taken |
| `source` | object | yes | `{ "catalog": { "entry": "context7", "version": "1.2.0" } }` (`version` defaults to the latest installable one) or `{ "custom": { "config": { ... } } }` |
| `repositories` | object | yes | `{ "include": "all" }` or `{ "include": [ { "name": "acme/*" }, { "id": "<uuid>" } ], "exclude": [ { "name": "acme/legacy" } ] }` |
| `inputs` | object | no | Non-secret values for the server's declared inputs |
| `credential_bindings` | object | no | Credential name to `account/<NAME>` or `repo/<NAME>` |
| `enabled` | boolean | no | Defaults to `false`. A server that is incomplete is saved off and reads `needs_setup`; enabling it with missing required fields is a `400` with an `issues` list of JSON pointers |

A custom `config` is internally tagged on `transport` (`local` with `command`, `args`, `environment`, or `remote_http` with `url`, `headers`). `{env:NAME}` inside `environment` and `headers` is a reference the runner resolves, never a value.

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Context7",
    "source": { "catalog": { "entry": "context7" } },
    "repositories": { "include": [ { "name": "acme/*" } ] },
    "credential_bindings": { "CTX7_KEY": "account/CTX7_KEY" },
    "enabled": true
  }' \
  https://api.forkline.dev/v1/mcp-servers
```

Response (`201 Created`): the `McpServer`.

### GET `/v1/mcp-servers`

Response (`200 OK`): `{ "servers": [ ... ] }`, each with `id`, `name`, `slug`, `enabled`, `generation`, a short `source`, `summary`, the `repositories` selector, `repository_count`, `matched` (the first three repository names and the count) and `status`.

### GET `/v1/mcp-servers/{id}`

Response (`200 OK`):

```json
{
  "id": "c3d4e5f6-...",
  "name": "Context7",
  "slug": "context7",
  "enabled": true,
  "generation": 3,
  "repositories": { "include": [ { "name": "acme/*" } ] },
  "source": {
    "kind": "catalog",
    "entry_id": "4d7e1b22-...",
    "external_id": "mcp-registry/io.github.upstash/context7",
    "title": "Context7",
    "version": "1.2.0",
    "tier": "verified",
    "latest_version": null,
    "latest_tier": null,
    "entry_available": true
  },
  "requirements": [],
  "inputs": {},
  "credential_bindings": { "CTX7_KEY": "account/CTX7_KEY" },
  "status": {
    "state": "working",
    "reason": null,
    "stage": null,
    "since": "2026-10-06T09:05:00Z",
    "repositories_total": 4,
    "repositories_ok": 4,
    "repositories_failing": 0,
    "repositories_needs_setup": 0,
    "repositories_not_running": 0,
    "repositories_not_used": 0,
    "top_problem": null,
    "last_reported_at": "2026-10-06T09:06:00Z"
  },
  "matched": { "count": 4, "names": ["acme/api", "acme/web", "acme/docs"] },
  "launch": { "transport": "local", "args": [], "env": [], "headers": [], "opencode_config": {} },
  "summary": "npx @upstash/context7-mcp@1.2.0",
  "created_at": "2026-10-06T09:00:00Z",
  "updated_at": "2026-10-06T09:05:00Z"
}
```

`launch` shows the saved server as it will run, with secrets as names, never values. `source.latest_version` is set when the entry has a newer installable version than the pinned one.

### PATCH `/v1/mcp-servers/{id}`

Request body (`UpdateMcpServerRequest`): any of `name`, `slug`, `enabled`, `repositories`, `inputs`, `credential_bindings`, `config` (custom servers only; a catalog server rejects it) and `generation`. Absent fields keep their value; the whole request is one transaction. Send the `generation` you read to get optimistic concurrency: a stale value is a `409`. Response (`200 OK`): the `McpServer`.

### DELETE `/v1/mcp-servers/{id}`

Response (`204 No Content`). The server is deleted, not archived.

### POST `/v1/mcp-servers/{id}/upgrade`

Request body: `version` (an exact version of the catalog entry), plus optional `inputs` and `credential_bindings` for anything the new version requires. The server keeps its inputs and bindings otherwise. Response (`200 OK`): the `McpServer`. Use `GET /v1/mcp-servers/{id}/upgrade-preview?version=` first to see what changes: the launch of both versions, whether the package or the publisher changed, and the requirements added or removed.

### GET `/v1/mcp-servers/{id}/repositories`

Lists the repositories the server reaches, evaluated with the same targeting rules task dispatch uses, with the state on each, problems first. It names repository owners and names, so the token needs both `mcp:read` and `repositories:read`; with only one of them the request is refused with `403`. Query parameters: `offset` (default 0) and `limit` (default 50, max 200). Response (`200 OK`): `{ "items": [ ... ], "total": 12, "next_offset": 50 }`; `next_offset` is `null` on the last page. A row with `platform: true` is a problem on our side: show a neutral message and not `reason`.

### GET `/v1/repositories/{id}/mcp-servers`

The effective view for one repository (`mcp:read` and `repositories:read`): `servers` (each with `server_id`, `name`, `slug`, `summary` and that repository's `status`), read-only `raw` entries from your account and repository `opencode_config.mcp` with `shadowed_by` when a server overrides one, and `warnings`.

### POST `/v1/mcp-servers/parse`

Request body: `{ "text": "<pasted JSON>" }`. Accepts the `mcpServers` / `servers` shape of Claude Code, Cursor and VS Code, and OpenCode's `mcp`. Response (`200 OK`): the detected `format` and the draft `servers`, each with `key`, `name`, `slug`, `config`, `credential_bindings`, `secret_hints` (literal secret-looking values to move into account secrets) and `problems`. Nothing is stored. A syntax error is a `400` with the line and column.

## Custom Providers

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/custom-providers` | `providers:manage` | List custom providers |
| POST | `/v1/custom-providers/import` | `providers:manage` | Import a custom provider |
| DELETE | `/v1/custom-providers/{provider_id}` | `providers:manage` | Delete a custom provider |

### GET `/v1/custom-providers`

```bash
curl -H "Authorization: Bearer $FORKLINE_TOKEN" \
  https://api.forkline.dev/v1/custom-providers
```

Response (`200 OK`):

```json
{
  "providers": [
    {
      "id": "cp-1234-...",
      "provider_id": "my-provider",
      "name": "My Provider",
      "base_url": "https://api.my-provider.com/v1",
      "created_at": "2026-09-01T10:00:00Z"
    }
  ]
}
```

### POST `/v1/custom-providers/import`

Imports a custom provider from an OpenCode provider configuration object.

Request body (`ImportCustomProviderRequest`):

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `provider` | object | yes | Map with a single key (provider ID) and its configuration |

```bash
curl -X POST \
  -H "Authorization: Bearer $FORKLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": {
      "my-provider": {
        "name": "My Provider",
        "options": { "baseURL": "https://api.my-provider.com/v1" }
      }
    }
  }' \
  https://api.forkline.dev/v1/custom-providers/import
```

Response (`201 Created`): Returns the `CustomProvider` object.

### DELETE `/v1/custom-providers/{provider_id}`

Deletes a custom provider by its database ID. No request body.

Response: `204 No Content`

---

## API Documentation

| Method | Path | Scope | Description |
|--------|------|-------|-------------|
| GET | `/v1/docs` | (public) | Interactive Swagger UI |
| GET | `/v1/docs/openapi.json` | (public) | OpenAPI specification |
