# Starnus Public API — Reference Documentation

**Version:** 1.0.0

**Base URL:**`https://api.starnus.com/v1`

**WebSocket URL:**`wss://0vxd7s295f.execute-api.eu-central-1.amazonaws.com/prod`

**OpenAPI spec:** [`GET /v1/openapi.json`](https://api.starnus.com/v1/openapi.json) — no auth required, import into Postman or Swagger UI

* * *

## 1\. Quickstart

Get from zero to a running AI execution in under 5 minutes.

### Step 1 — Install the SDK (recommended)

bashCopy

```bash
pip install starnus
```

### Step 2 — Get your API key

Open the Starnus dashboard → **Dev Tools → API Keys** → **Create Key**. Copy the `sk_live_...` value — it is shown only once.

### Step 3 — Run your first execution

pythonCopy

```python
import starnus

client = starnus.Starnus(api_key="sk_live_your_key_here")

# Create a project (or reuse an existing project_id)
project = client.projects.create(name="My First Project")

# Run a task and stream results in real time
execution = client.executions.create(
    project_id=project.id,
    prompt="Find 5 CEOs of software companies in the Netherlands and return their names and LinkedIn URLs.",
)

for event in execution.stream():
    if event.type == "supervisor_update":
        print(f"[supervisor] {event.message}")
    elif event.type == "plan":
        print(f"[plan]       {event.message}")
    elif event.type == "component_update":
        print(f"[agent]      {event.message}")
    elif event.type == "result":
        print(f"\n✅ Done ({event.status})\n{event.message}")
        break
```

### Step 4 — Retrieve the artifact

After the execution completes, your results are stored as an artifact:

pythonCopy

```python
artifacts = client.artifacts.list()
for art in artifacts["artifacts"]:
    print(art["name"], art["type"])

# Download as Excel
result = client.artifacts.export(artifact_id=art["id"], format="xlsx")
import requests, pathlib
data = requests.get(result["download_url"]).content
pathlib.Path("leads.xlsx").write_bytes(data)
print("Saved leads.xlsx")
```

> **No SDK?** Use raw HTTP + WebSocket. See [§20 WebSocket Streaming](/content/docs#20-websocket-streaming/index.html) and [§21 Code Examples](/content/docs#21-code-examples/index.html).

* * *

## 2\. Authentication

Every REST request must include your API key in the `Authorization` header:

httpCopy

```http
Authorization: Bearer sk_live_your_api_key_here
```

For the WebSocket, pass the key as a query parameter:

`wss://0vxd7s295f.execute-api.eu-central-1.amazonaws.com/prod?api_key=sk_live_your_key_here
`

API keys are created in **Dev Tools → API Keys** or via `POST /v1/api-keys`. Each key is shown **once** — store it securely in an environment variable, never in source code.

**Authentication error** — HTTP `401`:

jsonCopy

```json
{
  "error": {
    "code":    "authentication_error",
    "message": "Invalid API key",
    "status":  401
  }
}
```

* * *

## 3\. Errors & Status Codes

All error responses use a consistent envelope:

jsonCopy

```json
{
  "error": {
    "code":    "validation_error",
    "message": "Field 'name' is required",
    "status":  400
  }
}
```

| HTTP Status | Code | When it occurs |
| --- | --- | --- |
| `400` | `validation_error` | Missing or invalid field in the request body |
| `400` | `invalid_request` | Request is structurally valid but logically wrong |
| `400` | `invalid_json` | Request body is not valid JSON |
| `401` | `authentication_error` | Missing, invalid, or revoked API key |
| `403` | `forbidden` | Key is valid but the resource belongs to another account |
| `404` | `not_found` | Resource does not exist or you do not have access |
| `405` | `method_not_allowed` | HTTP method not supported on this route |
| `429` | `rate_limit_exceeded` | Too many requests — see [§4 Rate Limits](/content/docs#4-rate-limits/index.html) |
| `500` | `internal_error` | Unexpected server-side failure — safe to retry with exponential backoff |

* * *

## 4\. Rate Limits

Requests are counted per API key per rolling minute:

| Tier | Requests / minute |
| --- | --- |
| Free / Trial | 60 |
| Pro | 100 |
| Pro+ | 300 |
| Ultra | 300 |

Every response includes these headers:

| Header | Description |
| --- | --- |
| `X-RateLimit-Limit` | Maximum requests allowed per minute for your tier |
| `X-RateLimit-Remaining` | Requests remaining in the current window |
| `X-RateLimit-Reset` | Unix timestamp when the window resets |

When rate-limited, HTTP `429` is returned. Back off and retry after the `X-RateLimit-Reset` timestamp.

* * *

## 5\. Security Model

The Starnus Public API is designed so that **you never touch AWS resources directly** and sensitive internal identifiers never reach you.

| What is protected | How |
| --- | --- |
| Your Cognito `user_id` | Injected server-side from your API key — never returned in any response field |
| Internal S3 paths | `s3_key`, `s3_uri`, `bucket` stripped from all responses |
| OAuth tokens | `connected_account_id`, `config_id`, `api_key` for integrations stripped |
| Stripe IDs | `stripe_customer_id`, `stripe_subscription_id` stripped |
| Agent internals | Entire `tools` array (ARNs, component IDs, token refs) stripped from thread responses |
| API key hashes | `api_key_hash` stripped |

**Ownership is verified at the API layer.** Every read and write on a single resource (`GET`, `PATCH`, `DELETE`) checks that it belongs to your account. A `403` is returned if it does not.

**Whitelist sanitization.** Every response passes through a whitelist filter — only explicitly allowed fields are returned. Unknown or internal fields are dropped automatically.

* * *

## 6\. Profile

### `GET /v1/me`

Returns your account profile.

**Response `200`:**

jsonCopy

```json
{
  "profile": {
    "email":                    "john.doe@acme.com",
    "first_name":               "John",
    "last_name":                "Doe",
    "tier":                     "pro+ monthly",
    "subscription_status":      "active",
    "credits_remained":         180000.0,
    "credits_limit":            350000.0,
    "phone":                    "+31 20 123 4567",
    "bio":                      "Head of Growth at ACME Corp.",
    "work_info":                { "company": "ACME Corp", "role": "Head of Growth" },
    "external_links":           { "linkedin": "https://linkedin.com/in/johndoe" },
    "notification_preferences": { "email": true, "push": false }
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `email` | string | Read-only — cannot be changed via the API |
| `first_name` | string | First name |
| `last_name` | string | Last name |
| `tier` | string | `free`, `trial`, `pro monthly`, `pro+ monthly`, `ultra monthly` (and yearly variants) |
| `subscription_status` | string | `active`, `trialing`, `canceled`, `past_due` |
| `credits_remained` | number | Credits available this billing period |
| `credits_limit` | number | Total credits allocated this billing period |
| `phone` | string | Phone number (if set) |
| `bio` | string | Short biography (if set) |
| `work_info` | object | Structured work information (if set) |
| `external_links` | object | External profile links (if set) |
| `notification_preferences` | object | Notification settings (if set) |

* * *

### `PATCH /v1/me`

Update editable profile fields. Email and tier cannot be changed via the API.

**Request body** (all fields optional; provide at least one):

jsonCopy

```json
{
  "first_name":               "John",
  "last_name":                "Doe",
  "phone":                    "+31 20 123 4567",
  "bio":                      "Head of Growth at ACME Corp, building AI-powered sales tools.",
  "work_info":                { "company": "ACME Corp", "role": "Head of Growth" },
  "external_links":           { "linkedin": "https://linkedin.com/in/johndoe" },
  "notification_preferences": { "email": true, "push": false }
}
```

| Field | Type | Max length | Description |
| --- | --- | --- | --- |
| `first_name` | string | 1,000 | First name |
| `last_name` | string | 1,000 | Last name |
| `phone` | string | 1,000 | Phone number |
| `bio` | string | 1,000 | Short biography |
| `work_info` | object | — | Arbitrary structured work information |
| `external_links` | object | — | External links |
| `notification_preferences` | object | — | `{"email": true, "push": false}` |

**Response `200`:** Same structure as `GET /v1/me`.

**Errors:**

- `400 invalid_request` — No valid fields provided, or a string field exceeds 1,000 characters
- `400 invalid_request` — A dict field received a non-object value

* * *

## 7\. API Keys

### `GET /v1/api-keys`

List all API keys on your account. The plaintext key is never returned after creation.

**Response `200`:**

jsonCopy

```json
{
  "keys": [\
    {\
      "key_id":       "3bb67544-bcdb-496f-9ab2-7b9802f710a9",\
      "key_prefix":   "sk_live_NBAYNDv",\
      "label":        "Production integration",\
      "status":       "active",\
      "created_at":   "2026-01-15T10:00:00+00:00",\
      "updated_at":   "2026-01-15T10:00:00+00:00",\
      "last_used_at": "2026-03-20T14:32:00+00:00",\
      "usage_count":  42\
    }\
  ],
  "count": 1
}
```

| Field | Type | Description |
| --- | --- | --- |
| `key_id` | string (UUID) | Opaque identifier — use to revoke the key |
| `key_prefix` | string | First characters of the key — safe to display in a UI |
| `label` | string | Human-readable label |
| `status` | string | `active` or `revoked` |
| `last_used_at` | string (ISO 8601) | Last usage timestamp |
| `usage_count` | integer | Total API calls made with this key |

* * *

### `POST /v1/api-keys`

Create a new API key. The `plaintext_key` is returned **once only** — it cannot be retrieved again.

**Maximum:** 10 active keys per account.

**Request body:**

jsonCopy

```json
{
  "label": "Production integration"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | string | ✅ | Human-readable name. Max 100 characters. |

**Response `201`:**

jsonCopy

```json
{
  "key": {
    "key_id":        "3bb67544-bcdb-496f-9ab2-7b9802f710a9",
    "key_prefix":    "sk_live_NBAYNDv",
    "plaintext_key": "sk_live_NBAYNDvXXXXXXXXXXXXXXXXXXXXXXX",
    "label":         "Production integration",
    "status":        "active",
    "created_at":    "2026-03-22T15:16:30+00:00",
    "last_used_at":  null,
    "usage_count":   0
  }
}
```

> **Security:** Store `plaintext_key` immediately and securely (environment variable / secrets manager). It will **not** be shown again.

**Errors:**

- `400 invalid_request` — `label` is missing or blank
- `400 invalid_request` — `label` exceeds 100 characters
- `400 invalid_request` — 10 active keys already exist

* * *

### `DELETE /v1/api-keys/{key_id}`

Permanently revoke an API key. Revoked keys cannot be re-activated.

**Path parameter:**

| Parameter | Description |
| --- | --- |
| `key_id` | From `GET /v1/api-keys` → `key_id` field |

**Response `200`:**

jsonCopy

```json
{
  "message": "API key revoked successfully",
  "key_id":  "3bb67544-bcdb-496f-9ab2-7b9802f710a9"
}
```

* * *

## 8\. Projects

Projects are workspaces that group tasks, threads, files, and artifacts together.

### Space Types

Every project is one of two space types, identifiable by its `id` field:

| Space type | `id` prefix | Description |
| --- | --- | --- |
| **Free Space** | Regular UUID (e.g. `df8a2d7c-...`) | A standard project you create manually. You drive all prompts and tasks. |
| **Autopilot Outbound** | `auto_outbound_<uuid>` | An AI-managed outbound campaign. A 4-week execution plan is generated at creation. The AI supervisor runs goals autonomously on schedule, including a recurring Weekly Progress Report every Monday at 00:01 (local time). |
| **Autopilot Inbound** | `auto_inbound_<uuid>` | Reserved for future AI-managed inbound workflows (not yet generally available). |

> **Creating Autopilot Spaces:** Autopilot spaces are created from the Starnus platform dashboard, not via `POST /v1/projects`. The API `POST /v1/projects` endpoint always creates a **Free Space**.
>
> **Accessing Autopilot Spaces via API:** All standard project endpoints (`GET`, `PATCH`, `DELETE`) work on Autopilot spaces. Their tasks, threads, and artifacts are fully accessible.

* * *

### `GET /v1/projects`

List all your projects.

**Response `200`:**

jsonCopy

```json
{
  "projects": [\
    {\
      "id":          "df8a2d7c-2eef-4250-99ae-1d167c5e125a",\
      "name":        "Q1 Lead Generation",\
      "description": "Find and qualify B2B leads for ACME Corp's Q1 outreach.",\
      "created_at":  "2026-01-10T09:00:00+00:00",\
      "updated_at":  "2026-03-22T14:00:00+00:00"\
    }\
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string (UUID) | Project identifier — use in all project-scoped endpoints |
| `name` | string | Display name |
| `description` | string | Optional description |
| `created_at` | string (ISO 8601) | Creation timestamp |
| `updated_at` | string (ISO 8601) | Last modification timestamp |

* * *

### `POST /v1/projects`

Create a new project.

**Request body:**

jsonCopy

```json
{
  "name":        "Q1 Lead Generation",
  "description": "Find and qualify B2B leads for ACME Corp's Q1 outreach."
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | ✅ | Project name. Must be non-empty. |
| `description` | string | ❌ | Optional description. |

**Response `201`:** Returns `{"project": {...}}` with the same fields as the list.

**Errors:**

- `400 validation_error` — `name` is missing or blank

* * *

### `GET /v1/projects/{project_id}`

Get a single project.

**Response `200`:** Returns `{"project": {...}}`.

**Errors:**

- `403 forbidden` — Project belongs to another account
- `404 not_found` — Project not found

* * *

### `PATCH /v1/projects/{project_id}`

Update a project's name or description.

**Request body** (at least one field required):

jsonCopy

```json
{
  "name":        "Q1 Lead Generation (updated)",
  "description": "Updated description."
}
```

**Response `200`:** Returns `{"project": {...}}`.

* * *

### `DELETE /v1/projects/{project_id}`

Delete a project permanently.

**Response `204`:** No content.

* * *

## 9\. Tasks

Tasks are scheduled or on-demand instructions for the AI to execute. They are separate from real-time executions (which use WebSocket). Use tasks for recurring work, scheduled jobs, or queue-based workflows.

### `GET /v1/projects/{project_id}/tasks`

List tasks in a project. Returns the most recent tasks first.

**Query parameters:**

| Parameter | Type | Description |
| --- | --- | --- |
| `status` | string | Filter by status: `created`, `in_progress`, `done` |

**Response `200`:**

jsonCopy

```json
{
  "tasks": [\
    {\
      "id":                   "goal_4f3a1b2c5d6e",\
      "request_id":           "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",\
      "project_id":           "df8a2d7c-2eef-4250-99ae-1d167c5e125a",\
      "description":          "Find 50 B2B leads in Berlin",\
      "status":               "done",\
      "due_date":             "2026-04-01T00:00:00+00:00",\
      "recurring":            false,\
      "created_at":           "2026-03-22T08:00:00+00:00"\
    }\
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Task identifier. Autopilot weekly report tasks start with `goal_wrep_`. |
| `request_id` | string (UUID) | Auto-generated correlation ID — matches WebSocket streaming events for this execution |
| `project_id` | string | Project this task belongs to |
| `description` | string | The task prompt |
| `status` | string | `created` (queued), `in_progress`, `done` |
| `due_date` | string (ISO 8601) | When the task should run (for scheduled tasks) |
| `recurring` | boolean | Whether this task repeats |
| `recurrence_interval` | string | Recurrence pattern when `recurring` is `true` (e.g. `"weekly"`, `"daily"`) |
| `created_at` | string (ISO 8601) | Creation timestamp |
| `updated_at` | string (ISO 8601) | Last modification timestamp |

* * *

### `POST /v1/projects/{project_id}/tasks`

Create a new task. The server auto-generates a `request_id` — you do not supply one.

**Request body:**

jsonCopy

```json
{
  "description":         "Find 50 B2B leads in Berlin and add them to the Q1 Leads database",
  "due_date":            "2026-04-01T00:00:00+00:00",
  "files":               ["acme_prospects_a1b2c3d4_uuid.csv"],
  "recurring":           true,
  "recurrence_interval": "weekly"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | string | ✅ | The task prompt — what the agent should do |
| `due_date` | string (ISO 8601) | ❌ | When to execute. Omit for immediate execution. |
| `files` | array of strings | ❌ | File IDs from `POST /v1/files/confirm` to attach as context |
| `recurring` | boolean | ❌ | Set `true` with a `recurrence_interval` for recurring tasks |
| `recurrence_interval` | string | ❌ | Recurrence pattern: e.g. `"daily"`, `"weekly"`. Alternatively `recurrence_time_span` (integer minutes) is also accepted. |

**Response `201`:**

jsonCopy

```json
{
  "task": {
    "id":          "goal_4f3a1b2c5d6e",
    "request_id":  "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",
    "project_id":  "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
    "description": "Find 50 B2B leads in Berlin and add them to the Q1 Leads database",
    "status":      "created",
    "created_at":  "2026-03-22T15:00:00+00:00"
  }
}
```

> **`request_id` is auto-generated.** The server creates a UUID for every task. Use it to correlate WebSocket streaming events for this task's execution.

**Errors:**

- `400 validation_error` — `description` is missing or blank

* * *

### `PATCH /v1/projects/{project_id}/tasks/{task_id}`

Update a task's description, due date, or status.

**Request body** (at least one field required):

jsonCopy

```json
{
  "description": "Find 50 B2B leads in Amsterdam instead",
  "due_date":    "2026-04-15T00:00:00+00:00",
  "status":      "created"
}
```

**Response `200`:** Returns `{"task": {...}}`.

* * *

### `DELETE /v1/projects/{project_id}/tasks/{task_id}`

Delete a task.

**Response `204`:** No content.

* * *

## 10\. Threads (Execution History)

Threads are the persisted records of each AI execution — the prompt that was sent and the response that was returned. Every WebSocket execution creates a thread.

### `GET /v1/projects/{project_id}/threads`

List execution threads for a project, newest first.

**Query parameters:**

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `limit` | integer | 50 | Max threads to return (max 100) |

**Response `200`:**

jsonCopy

```json
{
  "threads": [\
    {\
      "id":         "req_7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",\
      "project_id": "df8a2d7c-2eef-4250-99ae-1d167c5e125a",\
      "prompt":     "Find 5 CEOs of software companies in the Netherlands",\
      "response":   "I found 5 CEOs and have listed them below with their LinkedIn profiles...",\
      "status":     "completed",\
      "created_at": "2026-03-22T15:18:00+00:00"\
    }\
  ],
  "count": 1
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Thread identifier — equals the `request_id` from the WebSocket acknowledgement |
| `project_id` | string | Associated project |
| `prompt` | string | The original user input sent via WebSocket |
| `response` | string | The agent's final response. Present for `completed`, `error`, and `awaiting_input`. Absent while still `running`. |
| `status` | string | `running`, `completed`, `error`, `stopped`, `awaiting_input` |
| `credits_consumed` | number | Credits used for this execution (when available) |
| `created_at` | string (ISO 8601) | When the execution started |

> **Threads vs Tasks:** Threads are read-only history records. Tasks are schedulable work items. A task execution creates a thread automatically.

* * *

### `GET /v1/threads/{thread_id}`

Get a single thread by ID.

**Query parameters:**

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | ✅ | The project the thread belongs to |

**Response `200`:** Returns `{"thread": {...}}` with the same fields as the list.

**Errors:**

- `400 validation_error` — `project_id` query parameter missing
- `403 forbidden` — Thread belongs to another account
- `404 not_found` — Thread not found

* * *

### `DELETE /v1/projects/{project_id}/threads/{thread_id}`

Delete a thread (execution history record).

**Response `204`:** No content.

* * *

## 11\. Artifacts

Artifacts are structured data outputs created by AI agents — lead databases, research documents, and images. Agents create and update artifacts automatically during execution.

| Type | Description |
| --- | --- |
| `database` | Spreadsheet-like table with named columns and rows |
| `document` | Markdown text document |
| `image` | Image file — accessible via time-limited presigned URL |

### `GET /v1/artifacts`

List all artifacts for your account.

**Query parameters:**

| Parameter | Type | Description |
| --- | --- | --- |
| `project_id` | string | Filter by project |
| `type` | string | Filter by type: `database`, `document`, or `image` |

**Response `200`:**

jsonCopy

```json
{
  "artifacts": [\
    {\
      "id":           "art_1d749beff51f",\
      "project_id":   "df8a2d7c-2eef-4250-99ae-1d167c5e125a",\
      "name":         "Berlin Leads Q1 2026",\
      "type":         "database",\
      "description":  "50 qualified B2B leads for Q1 Berlin outreach",\
      "size_bytes":   48200,\
      "row_count":    50,\
      "column_count": 8,\
      "version":      3,\
      "created_at":   "2026-03-22T08:02:43+00:00",\
      "updated_at":   "2026-03-22T09:15:00+00:00"\
    }\
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Artifact identifier |
| `project_id` | string | Associated project |
| `name` | string | Display name |
| `type` | string | `database`, `document`, or `image` |
| `description` | string | Short description |
| `size_bytes` | integer | Storage size |
| `row_count` | integer | Number of rows (database type only) |
| `column_count` | integer | Number of columns (database type only) |
| `version` | integer | Version number — incremented on each update |
| `content` | string / object | Inline content for `document` and `database` types |
| `download_url` | string | Presigned URL for `image` type |

* * *

### `POST /v1/artifacts`

Create a new artifact manually. Agents create artifacts automatically — use this endpoint to create one programmatically.

**Request body:**

jsonCopy

```json
{
  "project_id":  "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
  "name":        "Berlin Leads Q1 2026",
  "type":        "database",
  "description": "50 qualified B2B leads for Q1 Berlin outreach",
  "content": {
    "columns": [\
      { "id": "col_1", "name": "Company",  "type": "text" },\
      { "id": "col_2", "name": "Email",    "type": "text" },\
      { "id": "col_3", "name": "Status",   "type": "text" }\
    ],
    "rows": [\
      { "id": "row_1", "Company": "GlobalTech GmbH", "Email": "ceo@globaltech.de", "Status": "prospect" }\
    ]
  }
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `project_id` | string | ✅ | Project to create the artifact in |
| `name` | string | ✅ | Display name |
| `type` | string | ✅ | `database`, `document`, or `image` |
| `description` | string | ❌ | Short description |
| `content` | object / string | ❌ | Initial content — see structure above |

**Response `201`:** Returns `{"artifact": {...}}`.

* * *

### `GET /v1/artifacts/{artifact_id}`

Get a single artifact with its full content.

**Response `200`:**

jsonCopy

```json
{
  "artifact": {
    "id":          "art_1d749beff51f",
    "project_id":  "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
    "name":        "Berlin Leads Q1 2026",
    "type":        "database",
    "content": {
      "columns": [\
        { "id": "col_1", "name": "Company",  "type": "text" },\
        { "id": "col_2", "name": "Email",    "type": "text" },\
        { "id": "col_3", "name": "Status",   "type": "text" }\
      ],
      "rows": [\
        { "id": "row_1", "Company": "GlobalTech GmbH", "Email": "ceo@globaltech.de", "Status": "prospect" }\
      ]
    },
    "row_count":   1,
    "version":     3,
    "created_at":  "2026-03-22T08:02:43+00:00",
    "updated_at":  "2026-03-22T09:15:00+00:00"
  }
}
```

**Errors:**

- `404 not_found` — Artifact not found or belongs to another account

* * *

### `PATCH /v1/artifacts/{artifact_id}`

Update artifact metadata or patch its content (add/delete rows, add columns, update cells).

**Mode 1 — Update metadata:**

jsonCopy

```json
{
  "name":        "Berlin Leads Q1 2026 (final)",
  "description": "50 qualified leads, enriched with LinkedIn profiles"
}
```

**Mode 2 — Patch database content:**

jsonCopy

```json
{
  "add_rows": [\
    { "Company": "GlobalTech GmbH", "Email": "ceo@globaltech.de", "Status": "prospect" }\
  ],
  "delete_rows":  ["row_id_abc123"],
  "add_columns":  [{ "name": "LinkedIn", "type": "text" }],
  "update_cells": [\
    { "row_id": "row_id_abc123", "column_id": "col_3", "value": "qualified" }\
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `add_rows` | array of objects | Rows to add. Each object maps column names to values. |
| `delete_rows` | array of strings | Row IDs to delete |
| `add_columns` | array of objects | Columns to add: `{"name": "ColName", "type": "text"}` |
| `update_cells` | array of objects | `{"row_id": "...", "column_id": "...", "value": "..."}` |

When any patch field is present, content-patch mode is used. Otherwise metadata mode is used.

**Response `200`:** Returns `{"artifact": {...}}` with `version` incremented.

**Errors:**

- `400 validation_error` — No valid patch operations provided
- `404 not_found` — Artifact not found or belongs to another account

* * *

### `POST /v1/artifacts/{artifact_id}/export`

Export a database artifact to a downloadable file. Supported formats: `xlsx`, `csv`, `json`.

**Request body:**

jsonCopy

```json
{
  "format": "xlsx"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `format` | string | ✅ | `xlsx`, `csv`, or `json` |

> **Supported formats only:**`xlsx`, `csv`, `json`. Any other value returns `400 validation_error`.

**Response `200`:**

jsonCopy

```json
{
  "download_url": "https://users-documents.s3.amazonaws.com/...?X-Amz-...",
  "format":       "xlsx"
}
```

`download_url` is a presigned S3 URL valid for ~60 minutes. Fetch it with a simple `GET` — no authentication header required.

**Download example (Python):**

pythonCopy

```python
import requests, pathlib

result = requests.post(
    "https://api.starnus.com/v1/artifacts/art_1d749beff51f/export",
    headers={"Authorization": "Bearer sk_live_your_key"},
    json={"format": "xlsx"},
).json()

data = requests.get(result["download_url"]).content
pathlib.Path("leads.xlsx").write_bytes(data)
print(f"Saved {len(data):,} bytes")
```

**Errors:**

- `400 validation_error` — `format` is missing or not one of `xlsx`, `csv`, `json`
- `404 not_found` — Artifact not found or belongs to another account

* * *

### `DELETE /v1/artifacts/{artifact_id}`

Delete an artifact permanently.

**Response `204`:** No content.

* * *

## 12\. Files

Files are documents you upload to attach to task executions — PDFs, CSVs, prospect lists, research briefs, etc.

### Upload flow (3 steps)

`1.  POST /v1/files/upload-url   →  get a presigned S3 PUT URL
2.  PUT  <upload_url>            →  upload the file bytes directly to S3
3.  POST /v1/files/confirm       →  confirm the upload, receive your permanent file ID
`

After step 3, use the returned `id` in the `files` array when creating a task or sending a WebSocket execution.

* * *

### `POST /v1/files/upload-url`

Request a presigned upload URL.

**Request body:**

jsonCopy

```json
{
  "filename":     "acme_prospects.csv",
  "content_type": "text/csv",
  "project_id":   "df8a2d7c-2eef-4250-99ae-1d167c5e125a"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `filename` | string | ✅ | File name including extension |
| `content_type` | string | ✅ | MIME type: `text/csv`, `application/pdf`, `image/png`, etc. |
| `project_id` | string | ❌ | Associate with a project |

**Response `200`:**

jsonCopy

```json
{
  "upload_url":   "https://users-documents.s3.eu-central-1.amazonaws.com/temp/acme_prospects_a1b2c3d4.csv?...",
  "file_id":      "temp/acme_prospects_a1b2c3d4.csv",
  "filename":     "acme_prospects.csv",
  "content_type": "text/csv"
}
```

| Field | Description |
| --- | --- |
| `upload_url` | Presigned S3 `PUT` URL. Valid for ~10 minutes. **No `Authorization` header needed.** |
| `file_id` | Temporary ID — pass to `POST /v1/files/confirm` after uploading. |

* * *

### S3 Direct Upload

httpCopy

```http
PUT <upload_url>
Content-Type: text/csv

<raw file bytes>
```

S3 returns HTTP `200` or `204`. Do **not** include your Starnus `Authorization` header — the URL is self-authenticating. Complete within ~10 minutes of requesting the URL.

**Python example:**

pythonCopy

```python
import requests

with open("acme_prospects.csv", "rb") as f:
    resp = requests.put(
        upload_url,
        data=f,
        headers={"Content-Type": "text/csv"},
    )
resp.raise_for_status()  # S3 returns 200 on success
```

* * *

### `POST /v1/files/confirm`

Confirm the upload is complete. This moves the file to permanent storage and returns your permanent file ID.

**Request body:**

jsonCopy

```json
{
  "file_id":    "temp/acme_prospects_a1b2c3d4.csv",
  "project_id": "df8a2d7c-2eef-4250-99ae-1d167c5e125a"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `file_id` | string | ✅ | The `file_id` from `POST /v1/files/upload-url` |
| `project_id` | string | ❌ | Associate the file with a project |

**Response `200`:**

jsonCopy

```json
{
  "files": [\
    {\
      "id":     "acme_prospects_a1b2c3d4.csv",\
      "status": "valid"\
    }\
  ]
}
```

| Field | Description |
| --- | --- |
| `id` | Your permanent file ID — use this in `files` arrays. Contains no internal path or user ID. |
| `status` | `valid` = upload confirmed and ready to use |

* * *

### `GET /v1/files`

List uploaded files.

**Query parameters:**

| Parameter | Type | Description |
| --- | --- | --- |
| `project_id` | string | Filter by project |

**Response `200`:**

jsonCopy

```json
{
  "files": [\
    {\
      "id":           "acme_prospects_a1b2c3d4.csv",\
      "filename":     "acme_prospects.csv",\
      "content_type": "text/csv",\
      "size_bytes":   24500,\
      "status":       "valid",\
      "created_at":   "2026-03-22T14:00:00+00:00"\
    }\
  ]
}
```

* * *

### `GET /v1/files/{file_id}/download`

Get a download URL for a file. Returns HTTP `302` redirect to a presigned URL.

| Parameter | Description |
| --- | --- |
| `file_id` | The `id` returned by `POST /v1/files/confirm` |

**Response `302`:** Your HTTP client follows the redirect automatically. The presigned URL is valid for ~15 minutes.

`HTTP/1.1 302 Found
Location: https://users-documents.s3.amazonaws.com/...?X-Amz-...
`

> **Python `requests`:** Follows redirects by default. You receive the file content with status `200` — no special handling needed.

**Errors:**

- `404 not_found` — File not found or belongs to another account

* * *

## 13\. Integrations

Integrations connect Starnus to external services so AI agents can act on them (send emails, update Notion, search LinkedIn, etc.).

| Slug | Service | Auth method |
| --- | --- | --- |
| `gmail` | Gmail | Composio OAuth |
| `notion` | Notion | Composio OAuth |
| `slack` | Slack | Composio OAuth |
| `google_sheets` | Google Sheets | Composio OAuth |
| `google_calendar` | Google Calendar | Composio OAuth |
| `google_docs` | Google Docs | Composio OAuth |
| `googledrive` | Google Drive | Composio OAuth |
| `hubspot` | HubSpot | Composio OAuth |
| `linear` | Linear | Composio OAuth |
| `airtable` | Airtable | Composio OAuth |
| `outlook` | Outlook | Composio OAuth |
| `onedrive` | OneDrive | Composio OAuth |
| `x` | X (Twitter) | Composio OAuth |
| `linkedin` | LinkedIn | Unipile OAuth |
| `instagram` | Instagram | Unipile OAuth |
| `email` | SMTP outbound email | Smartlead credentials |

* * *

### `GET /v1/integrations`

List all integrations with their connection status.

**Response `200`:**

jsonCopy

```json
{
  "integrations": [\
    {\
      "name":         "gmail",\
      "app_type":     "composio",\
      "connected":    true,\
      "connected_at": null\
    },\
    {\
      "name":         "linkedin",\
      "app_type":     "unipile",\
      "connected":    false,\
      "connected_at": null\
    }\
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Integration slug — use in other endpoints |
| `app_type` | string | `composio`, `unipile`, or `smartlead` |
| `connected` | boolean | Whether the integration is connected |
| `connected_at` | string or `null` | When it was connected |

* * *

### `GET /v1/integrations/{name}/status`

Get the status of a single integration.

**Response `200`:** Returns `{"integration": {...}}`.

* * *

### `POST /v1/integrations/{name}/connect`

Start the OAuth authorization flow. Returns a URL to open in a browser.

**Response `200`:**

jsonCopy

```json
{
  "auth_url": "https://accounts.google.com/o/oauth2/v2/auth?..."
}
```

Open `auth_url` in a browser. After the user authorizes, the integration becomes `connected`.

> **Browser-only:** Cannot be completed programmatically. The URL must be opened in a browser.

* * *

### `DELETE /v1/integrations/{name}/disconnect`

Disconnect an integration and revoke access.

**Response `204`:** No content.

* * *

### `POST /v1/integrations/email`

Add an SMTP outbound email account (via Smartlead).

**Request body:**

jsonCopy

```json
{
  "email":    "outreach@acme.com",
  "name":     "ACME Outreach",
  "password": "app-password-here",
  "provider": "gmail"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | ✅ | Email address |
| `name` | string | ✅ | Display name |
| `password` | string | ✅ | App password (not your login password) |
| `provider` | string | ✅ | `gmail` or `outlook` |

**Response `201`:**`{"message": "Email account added successfully"}`

* * *

### `DELETE /v1/integrations/email/{email}`

Remove a connected email account.

**Response `204`:** No content.

* * *

## 14\. Billing

### `GET /v1/billing/balance`

Get your current credit balance and subscription status.

**Response `200`:**

jsonCopy

```json
{
  "balance": {
    "credits_remaining":   180000.0,
    "credits_limit":       350000.0,
    "tier":                "pro+ monthly",
    "subscription_status": "active"
  }
}
```

* * *

### `GET /v1/billing/plans`

List available subscription plans.

**Query parameters:**

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `currency` | string | `eur` | Currency: `eur`, `usd`, `gbp` |

**Response `200`:**

jsonCopy

```json
{
  "plans": [\
    {\
      "name":     "Pro",\
      "slug":     "pro_monthly",\
      "price":    50.0,\
      "interval": "monthly",\
      "currency": "eur",\
      "credits":  50000\
    },\
    {\
      "name":     "Pro+",\
      "slug":     "pro+_monthly",\
      "price":    125.0,\
      "interval": "monthly",\
      "currency": "eur",\
      "credits":  200000\
    },\
    {\
      "name":     "Ultra",\
      "slug":     "ultra_monthly",\
      "price":    250.0,\
      "interval": "monthly",\
      "currency": "eur",\
      "credits":  500000\
    }\
  ]
}
```

* * *

### `GET /v1/billing/invoices`

List past invoices.

**Response `200`:**

jsonCopy

```json
{
  "invoices": [\
    {\
      "amount":      125.0,\
      "currency":    "eur",\
      "description": "Starnus Pro+ Monthly",\
      "status":      "paid",\
      "date":        "2026-03-01T00:00:00+00:00",\
      "receipt_url": "https://pay.stripe.com/receipts/..."\
    }\
  ]
}
```

* * *

### `POST /v1/billing/checkout`

Create a Stripe checkout session to subscribe to a new plan.

**Request body:**

jsonCopy

```json
{
  "plan":     "pro_plus",
  "interval": "monthly"
}
```

**Response `200`:**`{"checkout_url": "https://checkout.stripe.com/..."}`

Open `checkout_url` in a browser to complete payment.

* * *

### `POST /v1/billing/upgrade`

Upgrade an existing subscription (no browser redirect).

**Request body:**`{"plan": "ultra"}`

**Response `200`:**`{"message": "Subscription upgraded successfully"}`

* * *

### `POST /v1/billing/cancel`

Cancel your subscription at the end of the current period.

**Response `200`:**`{"message": "Subscription cancelled successfully"}`

* * *

### `POST /v1/billing/activate-trial`

Activate the free trial.

**Response `200`:**`{"message": "Trial activated successfully"}`

* * *

## 15\. Usage

### `GET /v1/usage`

Get per-execution credit consumption records.

**Query parameters:**

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `limit` | integer | 50 | Max records (max 100) |

**Response `200`:**

jsonCopy

```json
{
  "usage": {
    "credits_consumed": 14888.0,
    "records": [\
      {\
        "credits_consumed": 463.0,\
        "project_id":       "df8a2d7c-2eef-4250-99ae-1d167c5e125a",\
        "project_name":     "Q1 Lead Generation",\
        "created_at":       "2026-03-22T08:02:43+00:00"\
      }\
    ],
    "count":    50,
    "has_more": true
  }
}
```

| Field | Description |
| --- | --- |
| `credits_consumed` (top level) | Total across all returned records |
| `records[].credits_consumed` | Credits for this specific execution |
| `has_more` | `true` if more records exist — reduce `limit` and paginate |

* * *

## 16\. Triggers

Triggers fire tasks automatically on a schedule or in response to integration events.

### `GET /v1/triggers`

**Response `200`:**

jsonCopy

```json
{
  "triggers": [\
    {\
      "id":          "a3f7b2e1-9d4c-4a1e-8b5f-123456789abc",\
      "description": "Summarize project status every Monday",\
      "type":        "webhook",\
      "schedule":    "weekly",\
      "active":      true,\
      "last_run":    "2026-03-17T09:00:00+00:00",\
      "next_run":    "2026-03-24T09:00:00+00:00",\
      "created_at":  "2026-03-22T10:00:00+00:00"\
    }\
  ],
  "count": 1
}
```

* * *

### `POST /v1/triggers`

Create a trigger.

**Request body:**

jsonCopy

```json
{
  "description": "Summarize project status every Monday",
  "type":        "schedule",
  "schedule":    "weekly"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | string | ✅ | Used as the task prompt when the trigger fires |
| `type` | string | ❌ | `schedule` (default) or `webhook` |
| `schedule` | string | ❌ | `daily`, `weekly`, or `monthly` (for `schedule` type) |
| `integration` | string | ❌ | Integration slug for `webhook` type (e.g. `gmail`) |

**Response `201`:** Returns `{"trigger": {...}}`.

* * *

### `DELETE /v1/triggers/{trigger_id}`

Delete a trigger. It stops firing immediately.

**Response `204`:** No content.

* * *

## 17\. Updates

Platform update notifications from the Starnus team.

### `GET /v1/updates`

Get unseen platform updates.

**Response `200`:**

jsonCopy

```json
{
  "updates": [\
    {\
      "id":         "a1b2c3d4-e5f6-7890-abcd-ef1234567890",\
      "title":      "New: WebSocket streaming supports artifact events",\
      "body":       "The `artifact_updated` event is now emitted in real time when an agent creates or modifies an artifact.",\
      "category":   "feature",\
      "summary":    "Real-time artifact notifications via WebSocket",\
      "action_url": null,\
      "seen":       false,\
      "created_at": "2026-03-15T09:00:00+00:00"\
    }\
  ]
}
```

* * *

### `POST /v1/updates/{update_id}/seen`

Mark an update as seen.

**Response `200`:**`{"message": "Update marked as seen"}`

* * *

## 18\. Support

### `POST /v1/support`

Submit a support request.

**Request body:**

jsonCopy

```json
{
  "subject":  "Gmail integration not connecting",
  "message":  "When I click Connect Gmail I receive an error. Steps: 1. Go to Integrations. 2. Click Connect next to Gmail. 3. Error appears.",
  "category": "technical"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | string | ✅ | Short issue description |
| `message` | string | ✅ | Detailed description. Include reproduction steps for bugs. |
| `category` | string | ❌ | `general`, `billing`, `technical`, `feature_request`, `bug_report`, `other`. Default: `general` |

**Response `200`:**`{"message": "Support request submitted successfully"}`

**Errors:**

- `400 validation_error` — `subject` or `message` is blank

* * *

## 19\. Expert Sessions

### `GET /v1/expert/sessions`

List your booked and completed expert sessions.

**Response `200`:**

jsonCopy

```json
{
  "sessions": [\
    {\
      "session_id":       "sess_abc123",\
      "status":           "scheduled",\
      "type":             "free",\
      "scheduled_at":     "2026-04-01T14:00:00+00:00",\
      "duration_minutes": 30,\
      "notes":            "Onboarding and integration setup"\
    }\
  ]
}
```

* * *

### `POST /v1/expert/sessions`

Request an expert session.

**Request body:**`{"type": "free"}`

**Response `201`:** Returns `{"session": {...}}`.

> **Browser flow:** After calling this endpoint, open the returned `booking_url` in a browser to complete scheduling.

* * *

## 20\. WebSocket Streaming

WebSocket is the primary channel for real-time AI task execution. Connect once, send tasks, and stream supervisor progress, agent updates, and final results as they happen.

**WebSocket URL:**

`wss://0vxd7s295f.execute-api.eu-central-1.amazonaws.com/prod?api_key=sk_live_your_key_here
`

> Use the **SDK** (`client.executions.create()`) for the simplest integration. See [§21 Code Examples](/content/docs#21-code-examples/index.html) for raw WebSocket examples.

* * *

### 20.1 Connection & Authentication

Append your API key as a query parameter:

`?api_key=sk_live_your_api_key_here
`

The connection is rejected immediately (HTTP `401`) if the key is invalid. The key is **never** transmitted in a WebSocket message body — only in the initial query string.

* * *

### 20.2 Sending a task execution

Send a `send_message` action after connecting:

jsonCopy

```json
{
  "action": "send_message",
  "payload": {
    "type":       "process",
    "user_input": "Find 5 CEOs of software companies in the Netherlands",
    "project_id": "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
    "files":      ["acme_report_uuid.pdf"],
    "integrations": {}
  }
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | string | ✅ | Always `"process"` for task execution |
| `user_input` | string | ✅ | The task prompt |
| `project_id` | string | ✅ | Project to run the task in |
| `files` | array | ❌ | File IDs from `POST /v1/files/confirm`. The server resolves them to S3 paths automatically — never pass S3 URIs directly. |
| `integrations` | object | ❌ | Pass `{}` or omit entirely |

> **`request_id` is auto-generated by the server.** Do not include it in your payload. The server creates a UUID, injects it, and echoes it back in the acknowledgement frame. All subsequent events for this execution carry the same `request_id`.

**Immediate acknowledgement frame (server → client):**

jsonCopy

```json
{
  "message":    "Success",
  "request_id": "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e"
}
```

Save `request_id` — every streaming event carries it, and you need it to stop the execution.

* * *

### 20.3 Received event types

| Event type | Description |
| --- | --- |
| `supervisor_update` | Progress text from the AI supervisor — what it is thinking or planning |
| `user_update` | A message explicitly directed at the end user (formatted for display) |
| `component_input` | The supervisor's agent execution plan — which agents will run and in what order |
| `component_update` | Progress from a running agent. May include `"wait_for_user": true` |
| `process_result` | **Final result.**`status` is `completed`, `error`, `stopped`, or `awaiting_input` |
| `process_todo_result` | Final result for a task triggered via the trigger/scheduler system |
| `stop_process_result` | Confirmation that a stop request was processed |
| `scheduling` | Confirmation that a task was deferred or scheduled |
| `artifact_updated` | An artifact was created or modified during execution |
| `error` | Server-side error during execution |
| `pong` | Heartbeat response |

**`supervisor_update` example:**

jsonCopy

```json
{
  "type":             "supervisor_update",
  "project_id":       "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
  "request_id":       "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",
  "supervisor_update": "Searching for CEOs of software companies in the Netherlands…"
}
```

**`component_input` example (execution plan):**

jsonCopy

```json
{
  "type":       "component_input",
  "project_id": "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
  "request_id": "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",
  "mode":       "agentic",
  "execution_plan": {
    "total_tasks": "1",
    "batches": [\
      {\
        "batch_id": "batch_1",\
        "tasks": [\
          {\
            "task_id":      "task_1",\
            "description":  "Find 5 CEOs of NL software companies",\
            "component_id": "sb1-lead-finder-agent"\
          }\
        ]\
      }\
    ]
  }
}
```

**`component_update` example:**

jsonCopy

```json
{
  "type":             "component_update",
  "project_id":       "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
  "request_id":       "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",
  "task_id":          "task_1",
  "component_update": "component sb1-lead-finder-agent-prod has started processing"
}
```

**`process_result` example (final):**

jsonCopy

```json
{
  "type":       "process_result",
  "project_id": "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
  "request_id": "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",
  "status":     "completed",
  "response":   "I found 5 CEOs and created the Leads artifact with their names and LinkedIn URLs.",
  "timestamp":  "2026-03-22T15:30:00+00:00"
}
```

`status` values on `process_result`:

| Status | Meaning |
| --- | --- |
| `completed` | Task finished successfully. `response` contains the final answer. |
| `awaiting_input` | The agent paused and is waiting for your next message. Start a new execution to continue. |
| `error` | Execution failed. `response` contains the error description. |
| `stopped` | You sent a `stop_process` and it was processed. |

* * *

### 20.4 Stopping an execution

jsonCopy

```json
{
  "action": "stop_process",
  "payload": {
    "type":       "stop_process",
    "project_id": "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
    "request_id": "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e"
  }
}
```

The server responds with a `stop_process_result` event.

* * *

### 20.5 Responding to a paused agent

When an agent sends `"wait_for_user": true` in a `component_update`, it is waiting for input:

jsonCopy

```json
{
  "type":           "component_update",
  "request_id":     "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",
  "component_id":   "comp_linkedin_outreach",
  "wait_for_user":  true,
  "message":        "Should I proceed with option 1 (bulk) or option 2 (personalized)?"
}
```

Respond with:

jsonCopy

```json
{
  "action": "user_response",
  "payload": {
    "type":         "user_update",
    "request_id":   "7e2a1c3d-4f5b-6a7c-8d9e-0f1a2b3c4d5e",
    "project_id":   "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
    "component_id": "comp_linkedin_outreach",
    "message":      "Option 2 — personalized subject lines.",
    "timestamp":    "2026-03-22T15:00:00+00:00"
  }
}
```

* * *

### 20.6 Heartbeat / keepalive

Send a ping every 15 seconds to prevent the connection from timing out:

jsonCopy

```json
{ "type": "ping", "timestamp": 1742654400000 }
```

The server responds with:

jsonCopy

```json
{ "type": "pong", "timestamp": 1742654400000 }
```

* * *

## 21\. Code Examples

### 21.1 Complete Python setup (raw WebSocket with full logging)

This example connects over raw WebSocket without the SDK, logs every sent/received frame to a JSON file, and handles all event types.

pythonCopy

```python
"""
starnus_ws_example.py — raw WebSocket execution with full logging.
Install: pip install websocket-client requests
"""

import json
import pathlib
import threading
import time
import uuid
from datetime import datetime, timezone

import requests
import websocket

# ── Config ────────────────────────────────────────────────────────────────────
API_KEY    = "sk_live_your_key_here"
REST_BASE  = "https://api.starnus.com/v1"
WS_URL     = "wss://0vxd7s295f.execute-api.eu-central-1.amazonaws.com/prod"
PROJECT_ID = "df8a2d7c-2eef-4250-99ae-1d167c5e125a"   # replace with your project

REST_HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type":  "application/json",
}

# ── Logging ───────────────────────────────────────────────────────────────────
LOG_FILE = pathlib.Path(f"starnus_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
_log: dict = {"events": []}

def _flush():
    LOG_FILE.write_text(json.dumps(_log, indent=2, default=str))

def log_http(direction: str, method: str, url: str, payload=None, response=None, status=None):
    entry = {
        "ts":        datetime.now(timezone.utc).isoformat(),
        "kind":      "http",
        "direction": direction,
        "method":    method,
        "url":       url,
    }
    if payload:  entry["payload"]  = payload
    if response: entry["response"] = response
    if status:   entry["status"]   = status
    _log["events"].append(entry)
    _flush()
    arrow = "→" if direction == "sent" else "←"
    print(f"\n[HTTP {arrow}] {method} {url}")
    if payload:  print(f"  SENT:     {json.dumps(payload, default=str)[:300]}")
    if response: print(f"  RECEIVED: {json.dumps(response, default=str)[:300]}")

def log_ws(direction: str, data: dict):
    entry = {
        "ts":        datetime.now(timezone.utc).isoformat(),
        "kind":      "websocket",
        "direction": direction,
        "data":      data,
    }
    _log["events"].append(entry)
    _flush()
    arrow = "→" if direction == "sent" else "←"
    msg_type = data.get("type") or data.get("action", "")
    print(f"\n[WS {arrow}] type={msg_type}")
    print(f"  {json.dumps(data, default=str)[:400]}")

# ── REST helpers ──────────────────────────────────────────────────────────────
def api_get(path: str):
    url = f"{REST_BASE}{path}"
    r = requests.get(url, headers=REST_HEADERS)
    result = r.json()
    log_http("received", "GET", url, response=result, status=r.status_code)
    return result

def api_post(path: str, body: dict):
    url = f"{REST_BASE}{path}"
    log_http("sent", "POST", url, payload=body)
    r = requests.post(url, headers=REST_HEADERS, json=body)
    result = r.json()
    log_http("received", "POST", url, response=result, status=r.status_code)
    r.raise_for_status()
    return result

# ── WebSocket execution ───────────────────────────────────────────────────────
request_id   = None   # filled from server acknowledgement
result_event = threading.Event()
final_result = {}

def on_open(ws):
    print("\n[WS] Connected")
    payload = {
        "action": "send_message",
        "payload": {
            "type":         "process",
            "user_input":   "Find 5 CEOs of software companies in the Netherlands",
            "project_id":   PROJECT_ID,
            "files":        [],
            "integrations": {},
        },
    }
    # NOTE: Do NOT include request_id — the server auto-generates it
    log_ws("sent", payload)
    ws.send(json.dumps(payload))

# Start heartbeat thread
    def heartbeat():
        while True:
            time.sleep(15)
            if ws.sock and ws.sock.connected:
                ping = {"type": "ping", "timestamp": int(time.time() * 1000)}
                log_ws("sent", ping)
                ws.send(json.dumps(ping))
    threading.Thread(target=heartbeat, daemon=True).start()

def on_message(ws, raw):
    global request_id
    data = json.loads(raw)
    log_ws("received", data)

msg_type = data.get("type", "")

# Capture server-assigned request_id from acknowledgement
    if data.get("message") == "Success" and data.get("request_id"):
        request_id = data["request_id"]
        print(f"\n[WS] Execution started. request_id = {request_id}")
        return

if msg_type == "supervisor_update":
        print(f"[supervisor] {data.get('supervisor_update', '')}")

elif msg_type == "user_update":
        print(f"[update]     {data.get('message', '')}")

elif msg_type in ("component_input", "component_todo_input"):
        plan = data.get("execution_plan", {})
        tasks = [t.get("description", "") for b in plan.get("batches", []) for t in b.get("tasks", [])]
        print(f"[plan]       {'; '.join(tasks)}")

elif msg_type == "component_update":
        if data.get("wait_for_user"):
            print(f"[PAUSED ⏸]  Agent waiting: {data.get('message', '')}")
            # Respond here if needed:
            # ws.send(json.dumps({"action": "user_response", "payload": {...}}))
        else:
            print(f"[agent]      {data.get('component_update', '')}")

elif msg_type in ("process_result", "process_todo_result"):
        final_result.update(data)
        print(f"\n✅ Result ({data.get('status')}):\n{data.get('response', '')}")
        result_event.set()
        ws.close()

elif msg_type == "stop_process_result":
        final_result.update(data)
        result_event.set()
        ws.close()

elif msg_type == "error":
        final_result.update(data)
        print(f"\n❌ Error: {data.get('error', 'Unknown')}")
        result_event.set()
        ws.close()

elif msg_type == "pong":
        print("[WS] Pong ♥")

elif msg_type == "artifact_updated":
        print(f"[artifact]   Created/updated: {data.get('artifact_id')}")

def on_error(ws, error):
    print(f"\n[WS] Error: {error}")

def on_close(ws, code, msg):
    print(f"\n[WS] Closed (code={code})")
    result_event.set()

def run():
    ws_url_with_key = f"{WS_URL}?api_key={API_KEY}"
    ws = websocket.WebSocketApp(
        ws_url_with_key,
        on_open=on_open,
        on_message=on_message,
        on_error=on_error,
        on_close=on_close,
    )
    thread = threading.Thread(target=ws.run_forever, kwargs={"ping_interval": 0})
    thread.daemon = True
    thread.start()

# Wait up to 10 minutes for a result
    result_event.wait(timeout=600)
    print(f"\nLog saved to {LOG_FILE}")
    return final_result

if __name__ == "__main__":
    result = run()
    print(f"\nFinal status: {result.get('status')}")
```

* * *

### 21.2 File upload + task with attachment (Python)

pythonCopy

```python
"""
Upload a PDF and run a task that references it.
"""
import pathlib, requests

API_KEY   = "sk_live_your_key_here"
REST_BASE = "https://api.starnus.com/v1"
H         = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}

# 1. Request presigned upload URL
resp = requests.post(f"{REST_BASE}/files/upload-url", headers=H, json={
    "filename":     "acme_report.pdf",
    "content_type": "application/pdf",
    "project_id":   "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
})
resp.raise_for_status()
upload_data = resp.json()
upload_url  = upload_data["upload_url"]
temp_id     = upload_data["file_id"]
print(f"Got upload URL. temp file_id = {temp_id}")

# 2. Upload directly to S3 — no Starnus auth header
with open("acme_report.pdf", "rb") as f:
    s3_resp = requests.put(upload_url, data=f, headers={"Content-Type": "application/pdf"})
s3_resp.raise_for_status()
print("File uploaded to S3 ✓")

# 3. Confirm the upload — get permanent file ID
resp = requests.post(f"{REST_BASE}/files/confirm", headers=H, json={
    "file_id":    temp_id,
    "project_id": "df8a2d7c-2eef-4250-99ae-1d167c5e125a",
})
resp.raise_for_status()
permanent_id = resp.json()["files"][0]["id"]
print(f"Permanent file ID: {permanent_id}")

# 4. Create a task with the file attached
# (or use the WebSocket example above and pass permanent_id in the files array)
resp = requests.post(
    f"{REST_BASE}/projects/df8a2d7c-2eef-4250-99ae-1d167c5e125a/tasks",
    headers=H,
    json={
        "description": "Summarize the key findings from the attached ACME report and create a briefing artifact",
        "files":       [permanent_id],
    },
)
resp.raise_for_status()
task = resp.json()["task"]
print(f"Task created: {task['id']} — request_id: {task['request_id']}")
```

* * *

### 21.3 Export artifact as Excel, CSV, and JSON (Python)

pythonCopy

```python
"""
Export the same artifact in all three supported formats.
"""
import pathlib, requests

API_KEY     = "sk_live_your_key_here"
REST_BASE   = "https://api.starnus.com/v1"
ARTIFACT_ID = "art_1d749beff51f"
H           = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}

for fmt, filename in [("xlsx", "leads.xlsx"), ("csv", "leads.csv"), ("json", "leads.json")]:
    # Request export
    resp = requests.post(
        f"{REST_BASE}/artifacts/{ARTIFACT_ID}/export",
        headers=H,
        json={"format": fmt},
    )
    resp.raise_for_status()
    download_url = resp.json()["download_url"]

# Download the file — no auth header needed for the presigned URL
    file_resp = requests.get(download_url)
    file_resp.raise_for_status()
    pathlib.Path(filename).write_bytes(file_resp.content)
    print(f"Saved {filename} ({len(file_resp.content):,} bytes)")
```

> **Supported export formats:**`xlsx`, `csv`, `json` — nothing else. Requesting `"excel"`, `"markdown"`, `"xls"`, etc. returns `400 validation_error`.

* * *

### 21.4 SDK quickstart (recommended)

pythonCopy

```python
"""
pip install starnus
"""
import pathlib, requests
import starnus

client = starnus.Client(api_key="sk_live_your_key_here")

# ── Create a project ──────────────────────────────────────────────────────────
project = client.projects.create(name="NL CEO Leads")
pid     = project["id"]
print(f"Project: {pid}")

# ── Run an execution and stream events ────────────────────────────────────────
execution = client.executions.create(
    project_id=pid,
    prompt="Find 5 CEOs of software companies in the Netherlands. Return names, companies, and LinkedIn URLs.",
)

for event in execution.stream():
    if event.type == "supervisor_update":
        print(f"[supervisor] {event.message}")
    elif event.type == "plan":
        print(f"[plan]       {event.message}")
    elif event.type == "component_update":
        print(f"[agent]      {event.message}")
    elif event.type == "waiting_for_user":
        answer = input(f"[Agent asks] {event.message}\nYour reply: ")
        execution.send_update(answer, event.component_id)
    elif event.type == "result":
        print(f"\n✅ Done ({event.status})\n{event.message}")
        break

# ── Retrieve and export the created artifact ──────────────────────────────────
arts = client.artifacts.list(project_id=pid)
for art in arts["artifacts"]:
    if art["type"] == "database":
        print(f"Artifact: {art['name']} ({art['row_count']} rows)")

# Export as Excel
        export = client.artifacts.export(artifact_id=art["id"], format="xlsx")
        data   = requests.get(export["download_url"]).content
        pathlib.Path("nl_ceo_leads.xlsx").write_bytes(data)
        print(f"Saved nl_ceo_leads.xlsx")

# ── Read conversation history ─────────────────────────────────────────────────
threads = client.threads.list(project_id=pid)
for thread in threads["threads"]:
    print(f"\n[{thread['status']}] {thread['prompt'][:80]}")
    if thread.get("response"):
        print(f"  → {thread['response'][:200]}")

# ── Clean up ──────────────────────────────────────────────────────────────────
client.projects.delete(pid)
```

* * *

### 21.5 Common errors and how to fix them

| Error | Cause | Fix |
| --- | --- | --- |
| `401 authentication_error` | API key missing, wrong, or revoked | Check `Authorization: Bearer sk_live_...` header. Create a new key if needed. |
| `400 validation_error: format must be one of: xlsx, json, csv` | Wrong export format (e.g. `"excel"`, `"markdown"`) | Use exactly `"xlsx"`, `"csv"`, or `"json"` |
| `400 validation_error: name is required` | Missing `name` when creating a project | Include `"name"` in the POST body |
| `404 not_found` | Resource doesn't exist or belongs to another account | Verify the ID. The API returns 404 (not 403) for security on ownership checks. |
| `429 rate_limit_exceeded` | Too many requests | Wait until `X-RateLimit-Reset` and retry with exponential backoff |
| `WS action not routed` | Sent wrong `action` key over WebSocket | Use exactly `"send_message"`, `"stop_process"`, or `"user_response"` |
| No WebSocket events received | `request_id` included in payload | Remove `request_id` from your send payload — the server auto-generates it |

* * *

## 22\. OpenAPI Specification

The full OpenAPI 3.0 spec is available at:

`GET https://api.starnus.com/v1/openapi.json
`

No authentication required.

**Import into Postman:**

1. Open Postman → Import → Link
2. Paste `https://api.starnus.com/v1/openapi.json`
3. All endpoints are imported with correct paths and schemas

**View in Swagger UI (browser):**

Open this URL:

`https://petstore.swagger.io/?url=https://api.starnus.com/v1/openapi.json
`

Or use [Redoc](https://redocly.github.io/redoc/?url=https://api.starnus.com/v1/openapi.json) for a cleaner read-only view.

**Import into Insomnia:**

1. Create → Import → From URL
2. Paste `https://api.starnus.com/v1/openapi.json`

* * *

## 23\. BYOK Keys (Bring Your Own LLM Key)

Register your own LLM provider API keys so Starnus agents use your keys instead of the platform's shared pool. Useful for organizations with existing provider contracts or usage tracking requirements.

**Supported providers:**`anthropic`, `openai`, `gemini`, `perplexity`

> Authentication: all `/v1/byok-keys` routes accept either a Starnus API key (Bearer header) or a Cognito session token (frontend).

* * *

### `GET /v1/byok-keys`

List all registered BYOK keys for your account.

**Response `200`:**

jsonCopy

```json
{
  "keys": [\
    {\
      "provider":    "anthropic",\
      "key_prefix":  "sk-ant-api03-",\
      "enabled":     true,\
      "created_at":  "2026-03-01T10:00:00+00:00",\
      "updated_at":  "2026-03-01T10:00:00+00:00"\
    }\
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `provider` | string | LLM provider: `anthropic`, `openai`, `gemini`, or `perplexity` |
| `key_prefix` | string | First 10 characters of the registered key (for display only) |
| `enabled` | boolean | Whether this key is currently active |
| `created_at` | string (ISO 8601) | When the key was registered |
| `updated_at` | string (ISO 8601) | Last modification timestamp |

> The plaintext key is **never returned** — it is encrypted with KMS on registration and never exposed again.

* * *

### `POST /v1/byok-keys`

Register a new BYOK key (or replace the existing key for that provider).

**Request body:**

jsonCopy

```json
{
  "provider": "anthropic",
  "api_key":  "sk-ant-api03-your-key-here"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `provider` | string | ✅ | One of: `anthropic`, `openai`, `gemini`, `perplexity` |
| `api_key` | string | ✅ | Your plaintext API key — stored encrypted; never returned again |

**Response `201`:**

jsonCopy

```json
{
  "message":    "BYOK key registered successfully",
  "provider":   "anthropic",
  "key_prefix": "sk-ant-api03-"
}
```

**Errors:**

- `400 validation_error` — `provider` is missing or invalid
- `400 validation_error` — `api_key` is too short for the provider

* * *

### `PATCH /v1/byok-keys/{provider}`

Enable or disable an existing BYOK key without replacing it.

**Request body:**

jsonCopy

```json
{ "enabled": false }
```

**Response `200`:**`{"message": "BYOK key updated", "provider": "anthropic", "enabled": false}`

* * *

### `DELETE /v1/byok-keys/{provider}`

Permanently remove a registered BYOK key for a provider.

**Path parameter:**`provider` — one of `anthropic`, `openai`, `gemini`, `perplexity`

**Response `200`:**`{"message": "BYOK key deleted successfully"}`

**Errors:**

- `404 not_found` — No key registered for this provider
