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 — 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
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
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
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 and §21 Code Examples.
2. Authentication
Every REST request must include your API key in the Authorization header:
httpCopy
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
{
"error": {
"code": "authentication_error",
"message": "Invalid API key",
"status": 401
}
}
3. Errors & Status Codes
All error responses use a consistent envelope:
jsonCopy
{
"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 |
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
{
"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
{
"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 characters400 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
{
"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
{
"label": "Production integration"
}
| Field | Type | Required | Description |
|---|---|---|---|
label |
string | ✅ | Human-readable name. Max 100 characters. |
Response 201:
jsonCopy
{
"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_keyimmediately and securely (environment variable / secrets manager). It will not be shown again.
Errors:
400 invalid_request—labelis missing or blank400 invalid_request—labelexceeds 100 characters400 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
{
"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 APIPOST /v1/projectsendpoint 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
{
"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
{
"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—nameis missing or blank
GET /v1/projects/{project_id}
Get a single project.
Response 200: Returns {"project": {...}}.
Errors:
403 forbidden— Project belongs to another account404 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
{
"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
{
"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
{
"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
{
"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_idis 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—descriptionis 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
{
"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
{
"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_idquery parameter missing403 forbidden— Thread belongs to another account404 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
{
"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
{
"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
{
"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
{
"name": "Berlin Leads Q1 2026 (final)",
"description": "50 qualified leads, enriched with LinkedIn profiles"
}
Mode 2 — Patch database content:
jsonCopy
{
"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 provided404 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
{
"format": "xlsx"
}
| Field | Type | Required | Description |
|---|---|---|---|
format |
string | ✅ | xlsx, csv, or json |
Supported formats only:
xlsx,csv,json. Any other value returns400 validation_error.
Response 200:
jsonCopy
{
"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
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—formatis missing or not one ofxlsx,csv,json404 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
{
"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
{
"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
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
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
{
"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
{
"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
{
"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 status200— 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 |
Unipile OAuth | |
instagram |
Unipile OAuth | |
email |
SMTP outbound email | Smartlead credentials |
GET /v1/integrations
List all integrations with their connection status.
Response 200:
jsonCopy
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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—subjectormessageis blank
19. Expert Sessions
GET /v1/expert/sessions
List your booked and completed expert sessions.
Response 200:
jsonCopy
{
"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_urlin 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 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
{
"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_idis 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 samerequest_id.
Immediate acknowledgement frame (server → client):
jsonCopy
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{ "type": "ping", "timestamp": 1742654400000 }
The server responds with:
jsonCopy
{ "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
"""
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
"""
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
"""
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. returns400 validation_error.
21.4 SDK quickstart (recommended)
pythonCopy
"""
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:
- Open Postman → Import → Link
- Paste
https://api.starnus.com/v1/openapi.json - 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 for a cleaner read-only view.
Import into Insomnia:
- Create → Import → From URL
- 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-keysroutes 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
{
"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
{
"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
{
"message": "BYOK key registered successfully",
"provider": "anthropic",
"key_prefix": "sk-ant-api03-"
}
Errors:
400 validation_error—provideris missing or invalid400 validation_error—api_keyis too short for the provider
PATCH /v1/byok-keys/{provider}
Enable or disable an existing BYOK key without replacing it.
Request body:
jsonCopy
{ "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