---
name: melso-integration
description: Integrate Melso Task conversations and Cloud execution into an existing codebase using the REST API or MCP.
---

# Integrate Melso

Canonical URL: https://melso.ai/skill.md

Melso runs work in durable conversations called Tasks. A Run is one execution
attempt inside a Task. Store the Task ID in your application; follow-ups and
automatic retries create new Run IDs. A successful Run delivers work for review;
it does not complete the Task.

When asked to integrate Melso, inspect the codebase, use its existing HTTP and
secret-management conventions, and implement Task creation, progress, results,
and follow-up input. Keep credentials on the server. Use the user's chosen
harness and funding method. Never print, commit, or send the API key in Task
content. Add environment variable names (without secret values) to the
project's configuration example. Test the integration and report which checks
actually ran.

## Authentication and setup

The public REST origin is **https://melso.ai**; the API base is
**https://melso.ai/api**. Paths below already include `/api`.
`MELSO_API_KEY` is a workspace API key (`mwk_…`) created in
**Settings → API keys**. No workspace ID is needed: a key belongs to one
workspace and implies it. It acts as its own integration identity, not as a
person, and reaches the Task and Run APIs. A key created with `admin` scope
also reaches the workspace administration a platform integration needs: the
member list, GitHub installations and repositories, the private network, Area
writes, pull requests and merges, and repository variables. Do not send
`X-Workspace-ID` with a key; one naming another workspace returns 403 with
code `workspace_key_scope`, as does any other management route, or an admin
route with a `tasks` key. `GET /api/me` shows the key's identity and its
`workspace`.
A personal access token (`mel_…`, **Profile → API tokens**) also works and acts
as its member. A person can belong to several workspaces, so a PAT needs
`X-Workspace-ID`; `GET /api/workspaces` lists that member's workspaces.
Workspace and Area access still apply.

Send these headers on workspace requests:

```http
Authorization: Bearer <MELSO_API_KEY>
Content-Type: application/json
```

A Melso API key authenticates your integration. A provider API key pays for model
use; these are different secrets. A workspace API key's Runs use its own funding
policy: Melso Gateway credits by default, or a provider API key saved in that
policy; never a person's subscription account. With a PAT, configure an
available harness in **Settings → AI providers**, using your own provider key,
Melso Gateway credits, or your personal connected subscription account.

## Choose a harness and model

Cloud currently offers `codex`, `claude`, `cursor`, and `pi` (shown as Melso).
`pi` is Gateway-only: its models come from the Melso Gateway catalog, with Qwen3.8 27B (the
default) and DeepSeek V4 Pro listed first when available. Do not silently
substitute a different harness.

`GET /api/harnesses/{harness}/models` returns 200 with `models` and `supported`.
Choose a model's `id`, a `thinking.supported_levels[].value` when advertised,
and a `service_tiers[].id` when advertised. Do not invent model IDs or effort
and tier enums. Omit optional overrides to use defaults. For `pi`, an empty catalog with `supported: false` means Gateway is
unconfigured. A 502 means Gateway catalog fetching failed; a 503 means the
catalog is unavailable; an unknown harness returns 404. Configuring a model is
not proof that execution is available. This route requires human credentials,
including a PAT.

## Tasks and output

`POST /api/tasks` creates a Task (201). For immediate execution:

```json
{
  "client_request_id": "<caller-generated-uuid>",
  "input": "Review this proposal and return a concise recommendation.",
  "area_id": "<optional-area-uuid>",
  "execution": {
    "harness": "codex",
    "model": "<id-from-models>",
    "thinking_level": "<value-from-models>",
    "service_tier": "<id-from-models>"
  }
}
```

Replace placeholders; omit `area_id` for Company context and omit optional
execution overrides you do not need. `input` is the conversation instruction.
An optional `title` overrides the title derived from the input; `description`
is only a summary. Supplying `execution` requires nonempty `input`. Omitting
`execution` saves the Task and its input without starting a Run.

Create returns 409 with `code: "active_duplicate_task"` and the existing
`task` when an open Task in the same Area and parent already has the same
title, compared ignoring case and extra whitespace. A title derived from
`input` counts. Continue that Task, or send `allow_duplicate: true` when a
second Task is intended.

Persist a UUID `client_request_id` before the request. Reuse it with identical
arguments after an uncertain create response; changing the payload under the
same key conflicts (409, code `client_request_conflict`). The response is the
Task object: `id` is durable, `run_id` is optional. An idempotent replay
returns the original Task in its current state and the same `run_id` when the
original create started a Run. It is answered before callback, execution and
capability checks, so a retry is not refused once the Task exists. Do not
create another Task to continue an existing conversation.

Follow-ups take the same field. Persist a fresh UUID `client_request_id` for
each `POST /api/tasks/{id}/input` and resend the identical body after a network
error, timeout, or 5xx: the retry returns 201 with the original `message_id`,
`run_id`, and `queued` instead of sending the input twice, even if the Task
has since completed or been cancelled. A different `content`, set of
`attachment_ids`, `fresh_session` or `max_attempts` under the same key
conflicts (409, code `client_request_conflict`). Keys are scoped to the Task
and to your API key.

A follow-up can configure the Run it creates. `fresh_session: true` starts a
new harness session instead of resuming the Task's previous one; the Run still
receives the Task's recent messages. `max_attempts: 1` gives that Run a single
attempt with no automatic retry, like `execution.max_attempts` on create. Both
hold while the Run waits in the queue behind other work.

| Action | Request and response |
| --- | --- |
| Read Task | `GET /api/tasks/{id}` → Task object (200) |
| List | `GET /api/tasks?limit=100&offset=0` → `{tasks, total}` (200); optional `area_id`, `creator_id`, `include_closed=true` |
| Search | `GET /api/tasks/search?q=proposal&limit=20&offset=0` → `{tasks, total}` (200); URL-encode `q`; max limit 50 |
| Start saved Task | `POST /api/tasks/{id}/start` with `{"harness":"codex"}` → `{run, execution}` (202); read `run.id`. Fields are flat, not nested under `execution`. Saved instructions are adopted once. |
| Send follow-up | `POST /api/tasks/{id}/input` with `{"content":"Include costs.","attachment_ids":[],"client_request_id":"<caller-generated-uuid>"}` → `{message_id, run_id, queued, attachment_ids, created_at}` (201; an identical retry returns the same body). Uses saved execution settings; may queue behind active work. Optional `fresh_session: true` and `max_attempts: 1` configure that Run. |
| Upload a file | `POST /api/upload-file` as multipart (not JSON) with `file` and optional `task_id` → attachment with `id` (200); pass IDs in `attachment_ids`. Keep the whole multipart request under 100 MiB. |
| Future settings | `GET /api/tasks/{id}/execution` → `{execution}` (200); `PUT` the flat `harness`, `model`, `thinking_level`, `service_tier` fields to change future turns, not an existing Run |
| Queue | `GET /api/tasks/{id}/queue` → `{run_id?, status?, created_at?, queued_runs?}` (200); `{}` means no queued/current attempt |
| Attempts | `GET /api/tasks/{id}/runs` → Run array (200), newest first; read `status`, `error`, `failure_reason`, `attempt`, `parent_run_id` |
| One attempt | `GET /api/runs/{runId}` → the Run object as `/runs` lists it (200); 404 for a Run you cannot see. When it ends, check `/runs` for a successor |
| Conversation result | `GET /api/tasks/{id}/messages` → the oldest 2000 messages, oldest first (200); read `role`, `content`, `run_id`, `attachments` |
| Page conversation | `GET /api/tasks/{id}/messages/page?limit=100` → `{messages, limit, has_more, next_cursor}`; the first page is the newest, chronological within the page; pass cursor `created_at` and `id` as `before_created_at` and `before_id` for older pages |
| Attempt trace | `GET /api/runs/{runId}/messages` → message array (200); `?since=<seq>` reads incremental messages |
| Attempt events | `GET /api/runs/{runId}/events` → `{events}` (200) |
| Cancel attempt | `POST /api/runs/{runId}/cancel?task_id={id}` → Run response (200); `task_id`, `expected_status`, and `queue_action` are query parameters, not body fields; leaves Task open |
| Pause attempt | `POST /api/runs/{runId}/pause` → Run response (200) for that Run; a live Cloud Run drains, then a `paused` successor appears in `/runs` |
| Resume | `POST /api/tasks/{id}/resume` → Run response (200) for the resumed successor, now `queued` |

`/input` on a Task with no execution settings returns 409; start the saved
Task with a harness first. `/messages` POST is discussion only and does not
schedule work. `/start`, `/execution`, and `/resume` require human credentials.
Do not mark a Task done just because your polling loop stopped.

## Wait on the Task, not one Run

A Task can gain Runs while you wait. Automatic retries, Cloud lifetime
rollovers, and pauses add a successor Run whose `parent_run_id` is the Run it
continues, often after that Run ended as `failed` or `cancelled`; follow-ups
add Runs too. Never declare an outcome because the first Run ended. A timeout
stops observation, not execution: retain the Task ID and resume observation
later.

### Stream the Task (recommended)

Prefer the event stream to polling. `GET /api/tasks/{id}/stream` with
`Accept: text/event-stream` keeps one Server-Sent Events connection open and
pushes the Task's changes as they happen:

| Event | `data` (same JSON as the REST read) |
| --- | --- |
| `task` | the `GET /api/tasks/{id}` body, whenever it changes |
| `run` | a `GET /api/tasks/{id}/runs` item, when a Run appears or changes status |
| `run.message` | a `GET /api/runs/{runId}/messages` item (trace) |
| `task.message` | a `GET /api/tasks/{id}/messages/page` item, when it enters the conversation |
| `interaction` | a question or approval, when created or resolved |

`stream.ready` follows the opening snapshot (the Task, its Runs and pending
interactions; add `?from=start` for the full history) and `stream.end` carries
the reason just before the server closes; neither has an `id`. Every other event's
`id` is a resume cursor: store it after you handled the event, and reconnect with
it as the `Last-Event-ID` header (or `?cursor=`). The server then sends exactly
what you missed, once, before going live. Streams close after 25 minutes and on
deploys; reconnect with the stored cursor, also when 45 seconds pass without the
`: ping` heartbeat. Stop instead when `stream.end` says `task_unavailable` or
`credential_invalid`. Decide the outcome with the rules under Polling below,
applied to the latest `task`, `run` and `interaction` events: the reply is
the `task.message` with `role: "assistant"` and the completed Run's `run_id`.
Opening a stream costs one request of the rate limit; events are free. To
follow many Tasks, open one stream per Task. Reference:
https://melso.ai/docs/task-stream

```bash
curl -sSN https://melso.ai/api/tasks/$MELSO_TASK_ID/stream \
  -H "Authorization: Bearer $MELSO_API_KEY" -H "Accept: text/event-stream"
```

### Webhooks

Use outbound webhooks when you cannot keep a connection open, and reconcile
with slow polling. Five events exist:
`task.delivered`, `task.needs_input`, `task.failed` (a Run failed and
nothing will retry it), `task.completed`, and `task.cancelled` (the Task, not
just a Run, was cancelled). Subscribe per Task or per workspace:

- Send `callback_url` (an HTTPS URL) on `POST /api/tasks`; it receives all
  five events for that Task. It requires the workspace signing secret: create
  returns 409 until one exists.
- A workspace owner or admin registers endpoints in **Settings → Webhooks** or
  with `POST /api/workspaces/{workspaceId}/webhooks` and
  `{"url":"https://…","event_types":["task.delivered","task.failed"]}`. The
  first endpoint returns `signing_secret` once;
  `POST /api/workspaces/{workspaceId}/webhooks/signing-secret/rotate` creates
  or replaces it. Endpoints and callbacks share this secret.

Each delivery is a JSON POST with `Melso-Event-ID` and
`Melso-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 keyed
with the literal secret string over `t + "." + raw body`. Verify the raw bytes
before parsing JSON, compare in constant time, and reject stale timestamps.
Delivery is at least once and unordered, with retries for 24 hours:
de-duplicate by the event `id`, then read current state from the API, because
a payload is a snapshot. A Run that finishes with neither text nor files, or
that is cancelled because credits ran out, sends no event, so keep polling
slowly as a backstop. Reference: https://melso.ai/docs/webhooks

### Polling

Poll with backoff (start at 1 second, multiply by 1.5, cap at 10 seconds),
jitter, and an overall timeout (such as 10 minutes). Decide from Runs and
interactions, not from the Task `status` field alone:

1. `GET /api/tasks/{id}`: `cancelled_at` or `completed_at` means the Task
   is closed.
2. `GET /api/tasks/{id}/interactions`: an entry with `status: "pending"`
   needs a person. Present its `title`, `message`, and `payload`. Do not
   fabricate an answer or bypass consent.
3. `GET /api/tasks/{id}/runs` (newest first): keep waiting while any Run is
   `queued`, `dispatched`, `running`, or `deferred`. `/queue` shows the
   same active work. Reset per-Run event or message cursors when Run IDs change.
4. When no Run is active, the newest Run is the outcome:
   - `completed`: the reply is the newest `assistant` message whose `run_id`
     is that Run's `id`; search `/messages/page` from the newest page back.
     Melso redacts secret-looking values. A reply that arrived double-escaped,
     with `\n` or `\r` escapes and no real line break inside it, is
     decoded; so is any single-line reply containing them, such as the path
     `C:\new\folder`. Other replies keep their backslashes. The Run's
     `result.output` is the raw harness output, so prefer the message.
     `kind: "no_response"` means no text reply. Delivered work awaits review;
     it does not complete the Task.
   - `failed`: report `error` and `failure_reason`; nothing will retry it.
   - `cancelled`: stopped. `failure_reason`, when set, says why, for example
     `credits_exhausted`; a plain cancel by a person has none.
   - `paused`: waits for `POST /api/tasks/{id}/resume`.

Keep run traces (`/api/runs/{runId}/messages` and `/events`) for
diagnostics, not answers.

The examples below create one Task and observe it. Persist `MELSO_REQUEST_ID`
(a UUID) for create retries, or set `MELSO_TASK_ID` to resume an existing Task
without creating anything. Both stop with an error on HTTP failures; production
clients should retry transient read failures with bounded backoff and honor
`Retry-After`. Only retry creates and follow-up input with the same persisted
idempotency key and payload. Never retry arbitrary writes automatically.

### TypeScript (Node.js 22.18+)

Save as `melso-example.mts` and run `node melso-example.mts`. Configure
`MELSO_API_KEY` and `MELSO_REQUEST_ID`; optionally set `MELSO_HARNESS`
(default `codex`) and `MELSO_API_BASE` for a local server origin.

```typescript
const base = process.env.MELSO_API_BASE ?? "https://melso.ai";
const key = process.env.MELSO_API_KEY; // workspace API key (mwk_…) from Settings → API keys; it implies its workspace
if (!key) throw new Error("Set MELSO_API_KEY");
const deadline = Date.now() + 10 * 60_000;

function object(value: unknown): Record<string, unknown> {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("Expected an API object");
  }
  return value as Record<string, unknown>;
}
function list(value: unknown): Record<string, unknown>[] {
  if (!Array.isArray(value)) throw new Error("Expected an API array");
  return value.map(object);
}
async function api(path: string, body?: unknown): Promise<unknown> {
  const remaining = deadline - Date.now();
  if (remaining <= 0) throw new Error("Observation timed out; Task keeps running");
  const response = await fetch(base + path, {
    method: body === undefined ? "GET" : "POST",
    headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(Math.min(15_000, remaining)),
  });
  if (!response.ok) throw new Error("HTTP " + response.status + ": " + await response.text());
  return response.json();
}

let taskId = process.env.MELSO_TASK_ID;
if (!taskId) {
  const requestId = process.env.MELSO_REQUEST_ID;
  if (!requestId) throw new Error("Set a persistent UUID in MELSO_REQUEST_ID");
  const created = object(await api("/api/tasks", {
    client_request_id: requestId,
    input: "Reply with a short greeting for the integration smoke test.",
    execution: { harness: process.env.MELSO_HARNESS ?? "codex" },
  }));
  if (typeof created.id !== "string") throw new Error("Missing Task ID");
  taskId = created.id;
}
console.log("Task:", taskId); // Persist this ID in your application.
const path = "/api/tasks/" + taskId;
const active = ["queued", "dispatched", "running", "deferred"];

// The reply is the newest assistant message carrying the completed Run's ID.
async function reply(runId: string): Promise<Record<string, unknown> | undefined> {
  let query = "?limit=100";
  for (let pages = 0; pages < 20; pages++) {
    const page = object(await api(path + "/messages/page" + query));
    const found = list(page.messages).reverse()
      .find(message => message.run_id === runId && message.role === "assistant");
    if (found || page.has_more !== true || !page.next_cursor) return found;
    const cursor = object(page.next_cursor);
    query = "?limit=100&before_created_at=" + encodeURIComponent(String(cursor.created_at)) +
      "&before_id=" + encodeURIComponent(String(cursor.id));
  }
  return undefined;
}

let delay = 1000;
let seen = "";
while (true) {
  const task = object(await api(path));
  const asks = list(await api(path + "/interactions")).filter(ask => ask.status === "pending");
  const runs = list(await api(path + "/runs")); // Newest first.
  const latest = runs[0];
  if (latest && seen !== latest.id + " " + latest.status) {
    seen = latest.id + " " + latest.status;
    console.log("Run:", latest.id, latest.status, "attempt", latest.attempt);
  }
  if (task.cancelled_at || task.completed_at) {
    console.log("Task closed:", task.cancelled_at ? "cancelled" : "completed");
    break;
  }
  if (asks.length > 0) {
    // A person must answer; never fabricate a response.
    for (const ask of asks) console.log("Needs input:", ask.id, ask.title ?? "", ask.message);
    break;
  }
  if (!runs.some(run => active.includes(String(run.status)))) {
    if (!latest) {
      console.log("No Runs: start the saved Task with a harness.");
    } else if (latest.status === "completed") {
      const message = await reply(String(latest.id));
      console.log(message ? message.content : "Reply not found");
      for (const file of list(message?.attachments ?? [])) console.log("Attachment:", file.filename);
    } else if (latest.status === "paused") {
      console.log("Paused: resume with POST " + path + "/resume");
    } else {
      console.log("Stopped:", latest.status, latest.failure_reason ?? "", latest.error ?? "");
    }
    break;
  }
  const wait = Math.min(delay * (0.8 + Math.random() * 0.2), deadline - Date.now());
  if (wait <= 0) throw new Error("Observation timed out; resume with MELSO_TASK_ID=" + taskId);
  await new Promise(resolve => setTimeout(resolve, wait));
  delay = Math.min(10_000, delay * 1.5);
}
```

### Python (standard library)

Save as `melso_example.py` and run `python3 melso_example.py` with the same
environment variables. No third-party dependencies are required.

```python
import json
import os
import random
import time
import urllib.error
import urllib.parse
import urllib.request

base = os.environ.get("MELSO_API_BASE", "https://melso.ai")
# MELSO_API_KEY: workspace API key (mwk_…) from Settings → API keys; it implies its workspace.
headers = {"Authorization": "Bearer " + os.environ["MELSO_API_KEY"],
           "Content-Type": "application/json"}
deadline = time.monotonic() + 600


def api(path, body=None):
    remaining = deadline - time.monotonic()
    if remaining <= 0:
        raise TimeoutError("Observation timed out; Task keeps running")
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(base + path, data=data, headers=headers)
    try:
        with urllib.request.urlopen(request, timeout=min(15, remaining)) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:
        raise RuntimeError(f"HTTP {error.code}: {error.read().decode()}") from error


task_id = os.environ.get("MELSO_TASK_ID")
if not task_id:
    created = api("/api/tasks", {
        "client_request_id": os.environ["MELSO_REQUEST_ID"],
        "input": "Reply with a short greeting for the integration smoke test.",
        "execution": {"harness": os.environ.get("MELSO_HARNESS", "codex")},
    })
    task_id = created["id"]
print("Task:", task_id, flush=True)  # Persist this ID in your application.
path = f"/api/tasks/{task_id}"
active = {"queued", "dispatched", "running", "deferred"}


def reply(run_id):
    # The reply is the newest assistant message carrying the completed Run's ID.
    query = "?limit=100"
    for _ in range(20):
        page = api(path + "/messages/page" + query)
        for message in reversed(page.get("messages") or []):
            if message.get("run_id") == run_id and message.get("role") == "assistant":
                return message
        cursor = page.get("next_cursor")
        if page.get("has_more") is not True or not cursor:
            return None
        query = "?" + urllib.parse.urlencode({
            "limit": 100, "before_created_at": cursor["created_at"], "before_id": cursor["id"]})
    return None


delay = 1.0
seen = None
while True:
    task = api(path)
    asks = [ask for ask in api(path + "/interactions") if ask.get("status") == "pending"]
    runs = api(path + "/runs")  # Newest first.
    latest = runs[0] if runs else None
    if latest and seen != (latest["id"], latest["status"]):
        seen = (latest["id"], latest["status"])
        print("Run:", latest["id"], latest["status"], "attempt", latest.get("attempt"), flush=True)
    if task.get("cancelled_at") or task.get("completed_at"):
        print("Task closed:", "cancelled" if task.get("cancelled_at") else "completed")
        break
    if asks:
        # A person must answer; never fabricate a response.
        for ask in asks:
            print("Needs input:", ask["id"], ask.get("title") or "", ask.get("message", ""))
        break
    if not any(run.get("status") in active for run in runs):
        if latest is None:
            print("No Runs: start the saved Task with a harness.")
        elif latest["status"] == "completed":
            message = reply(latest["id"])
            print(message["content"] if message else "Reply not found")
            for file in (message or {}).get("attachments") or []:
                print("Attachment:", file.get("filename"))
        elif latest["status"] == "paused":
            print(f"Paused: resume with POST {path}/resume")
        else:
            print("Stopped:", latest["status"], latest.get("failure_reason", ""), latest.get("error") or "")
        break
    wait = min(delay * random.uniform(0.8, 1.0), deadline - time.monotonic())
    if wait <= 0:
        raise TimeoutError(f"Observation timed out; resume with MELSO_TASK_ID={task_id}")
    time.sleep(wait)
    delay = min(10, delay * 1.5)
```

## Pay for model use

For automated pipelines, prefer a dedicated provider project API key with a
provider-side spend limit. Stored BYOK is supported only for `codex` (OpenAI)
and `claude` (Anthropic). Configure it with your Melso PAT, not MCP credentials:

`PUT /api/provider-accounts/harnesses/codex`

```json
{
  "enabled": true,
  "accounts_enabled": false,
  "key_enabled": true,
  "api_key": "<dedicated-openai-project-key>",
  "gateway_enabled": false,
  "gateway_limit_ticks": "1000000000000"
}
```

Send the complete policy: omitted booleans become false. `gateway_limit_ticks`
is a decimal integer string, not a JSON number; it is required even when Gateway
is off. `1000000000000` is $100. Zero resets to the default $100 cap, not a zero
spend limit; use `gateway_enabled: false` to disable Gateway. The cap cannot be
below settled charges plus outstanding reservations (409). Omit `api_key` to
preserve it, send an empty string to remove it with `key_enabled: false`.
Keys are not returned. `GET /api/provider-accounts/harnesses` and a successful
PUT return `{harnesses}` with policy, key health, usage, and support flags.

Melso Gateway uses workspace credits, enabled through `gateway_enabled` and
bounded by the per-member, per-harness monthly cap. Check `supports_gateway`
and available credits; enablement alone does not fund execution. Codex, Claude,
and Pi support Gateway; Pi has no BYOK or account route.
Cursor requires a connected account and has no BYOK or Gateway route. Configure
billing/credits in Melso and never raise a budget without the user's
authorization.

Whichever route pays for the model, BYOK included, every Cloud Run also needs
workspace access and usable workspace credits, or an Enterprise workspace:
sandbox compute is billed to workspace credits. Without them the create still
succeeds, but the Run fails with `failure_reason` `free_credit_exhausted`,
`credits_exhausted`, or `workspace_access_required`, and nothing retries it.
Losing access or credits mid-run cancels the Run with the same reason. A person
must add credits or subscribe; then rerun it with `POST /api/tasks/{id}/rerun`
and `{"run_id":"<that-run-id>"}`, or send a follow-up.

A subscription account belongs to one person, for their own Tasks. Follow the
provider's terms. Use API keys for automated pipelines. Account connection
requires that person's consent; an agent must not approve it for them.

All paths below are prefixed with `/api/provider-accounts`:

| Method and suffix | Purpose / success |
| --- | --- |
| `GET /` | List visible account metadata: `{accounts}` (200) |
| `GET /{accountId}` | Get owned account metadata (200), never credentials |
| `POST /{accountId}/pause` | Stop use, retain credentials (204) |
| `POST /{accountId}/enable` | Re-enable a paused account (204) |
| `POST /{accountId}/disable` | Revoke stored credentials; reconnect before reuse (204) |
| `POST /{accountId}/default` | Set your default account for its provider (204) |
| `DELETE /defaults/{provider}` | Clear the provider default (204) |
| `POST /{accountId}/refresh-usage` | Request a usage refresh (204); read metadata again |
| `DELETE /{accountId}` | Delete the account (204) |
| `POST /auth-sessions` | Begin connection with `{provider, reconnect_id?}` (201) |
| `GET /auth-sessions/{sessionId}` | Read connection state (200) |
| `POST /auth-sessions/{sessionId}/submit` | Submit `{"code":"<human-provided-code>"}` for a hosted-code flow (200) |
| `DELETE /auth-sessions/{sessionId}` | Cancel unfinished connection (204) |

Connect using provider `codex`, `claude`, or `cursor` for the offered subscription
harnesses. Show the human the returned `prompt.url` and, when present,
`prompt.user_code`. `prompt.flow` is `device_code`, `hosted_code`, or
`cloud_pairing`. For hosted-code (Claude), wait for their consent and returned
code before `/submit`. Device-code and pairing flows complete through server
polling; poll the session with backoff until `connected`, `failed`, `expired`,
or `cancelled`, bounded by `expires_at`. Intermediate states include `queued`,
`pending_user`, `exchanging`, and `confirmed`. Only `connected` plus `account_id`
proves connection; receiving a URL does not. For a lost submit response, read
the session before attempting another one-use code exchange.

The provider backend also recognizes `muse`, but it is not in the offered Cloud
catalog. Do not treat backend provider support as Cloud harness availability.
Full reference: https://melso.ai/docs/provider-accounts

## MCP alternative

For an interactive external agent, connect to **https://mcp.melso.ai/mcp** using
OAuth and let the human choose the company, or all of theirs. For a headless
integration with no person behind it, use a workspace API key (`mwk_…`) on the
REST API instead. MCP credentials are scoped to MCP, not the general REST API,
and cannot manage provider accounts or harness funding. Configure funding with
a human PAT or in Settings first.

Discover tools with `tools/list`. Workspace connections can use `tasks_create`,
`tasks_get`, `tasks_send`, `tasks_control`, `tasks_wait`, and `execution_options`.
MCP create semantics differ from REST: supplied `input` starts by default using
applicable defaults; `start: false` saves only. `tasks_wait` holds one call open
for up to 600 seconds on Streamable HTTP clients that accept SSE (20 seconds for
JSON-only clients) and does not register a webhook. Read the full contract before
implementing MCP: https://melso.ai/docs/tasks-api

## Handle errors

Read the JSON `error`, optional `code`, and Cloud admission
`reason_code`; do not branch on prose alone.

| HTTP status | Action |
| --- | --- |
| 400 | Fix malformed UUIDs, missing input, unsupported harness/model/settings, or invalid funding policy. Do not retry unchanged. |
| 401 | Check expired/revoked Melso credentials. `provider_account_auth_required` instead requires provider reconnection. |
| 403 | Check membership/Area access and credential type. Run tokens and MCP OAuth grants cannot call human-only routes. Never bypass this by changing actor headers. |
| 404 | Check resource ID, workspace, access, and harness availability. |
| 408 | `provider_account_queue_timeout`: inspect the Task before deciding whether to retry work. |
| 409 | Inspect state before writing again: `active_duplicate_task` (the body includes the existing `task`), `client_request_conflict` (an idempotency key reused with a different payload; send new work under a new key), changed execution, busy/revoked account, cap conflict, missing webhook signing secret, or unavailable Cloud admission. For `provider_account_busy`, honor `Retry-After` (2 seconds). |
| 429 | Respect rate limiting and any `Retry-After` header; use bounded backoff. |
| 500 / 502 / 503 | Server or provider/catalog unavailable. Retry reads with bounded backoff; retry creates and follow-ups with their retained idempotency keys, and inspect other uncertain writes. |

Surface admission `reason_code` and `error` to the user. Missing funding, consent,
permissions, and exhausted budgets need a person to act, not an endless retry.
Keep provider-account responses and all credentials out of public logs.

## Isolated Tasks

When one Melso account creates Tasks for many of your end users, send
`"isolated": true` in `POST /api/tasks`. Runs of other Tasks cannot see an
isolated Task, and its own Runs work only inside it: other Tasks return 404,
and creating Tasks or changing workspace resources returns 403 with
`code: "isolated_task_scope"`. Its Runs get no shared workspace files or
memories. The flag is fixed at creation and every Task response includes
`isolated`; your API key still reads and manages these Tasks. Details:
https://melso.ai/docs/tasks-api#isolated-tasks

## Task MCP servers

To give one Task's Runs your own tools, such as a wiki or CRM authenticated
with a secret you mint for that Task, send `mcp_servers` in
`POST /api/tasks` or replace the whole set with
`PUT /api/tasks/{id}/mcp-servers` and `{"mcp_servers":[...]}` (`[]`
removes every server). A server is `{"name","url","headers"?}` (remote
streamable HTTP, `https` only) or `{"name","command","args"?,"env"?}`
(stdio). At most 10 per Task; names match `^[a-z0-9][a-z0-9-]{0,62}$`. A
Task server replaces a Company or Area server of the same name, and a new set
reaches Runs claimed after it. `GET /api/tasks/{id}/mcp-servers` returns
names, URLs, commands and header and env names, never values. Only workspace
owners, admins and API keys set them: other members get 403 with
`code: "task_mcp_servers_forbidden"`, and Run tokens get 403. Invalid sets
return 400 with `code: "task_mcp_servers_invalid"`. The values reach the
Run's harness configuration, where the agent can read them, so mint each
secret scoped to its Task and short-lived, and refuse it once the Task has no
live Run. A `client_request_id` replay stores a SHA-256 of the create body,
so use high-entropy secrets. Details:
https://melso.ai/docs/tasks-api#task-mcp-servers
