paperclipai/paperclip · error

CreateOS returned invalid JSON.

Error message

CreateOS returned invalid JSON.

What it means

Thrown by `json()` when the HTTP 200 response body cannot be parsed as JSON, or parses to a non-object, so the `{status, data}` envelope cannot be built. `response.json()` throwing (malformed/truncated/empty body, HTML error page) and `object()` rejecting a non-object are both collapsed into this single sanitized message — the raw body is never surfaced because it could contain private workspace names or credentials.

Solutions

  1. Fetch the same endpoint with curl and inspect the raw body and Content-Type to see what is actually returned.
  2. Verify `config.apiUrl` points at the CreateOS API and not a UI/login page or another service.
  3. Check for proxies/gateways that could inject HTML (auth walls, error interstitials) and exclude the API host from them.
  4. Ensure the operation is expected to return a body; if an endpoint legitimately returns 204/empty, call it via `request()` instead of `json()`.
  5. Retry — transient truncation of chunked bodies can produce this; persistent failure indicates a config/routing problem.

Example fix

// before: no way to tell why json() failed
const data = await client.json(`/sandboxes/${id}`, "GET");
// after: probe the endpoint's raw response first when debugging
const res = await fetch(`${apiUrl}/v1/sandboxes/${id}`, { headers: { "X-Api-Key": key } });
const text = await res.text();
try { JSON.parse(text); } catch {
  throw new Error(`Non-JSON body from CreateOS (first 200 chars): ${text.slice(0, 200)}`);
}
Defensive patterns

Strategy: try-catch

Validate before calling

const res = await fetch(`${config.apiUrl}/v1/sandboxes`, { headers: { "X-Api-Key": key } });
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) throw new Error(`Unexpected content-type: ${ct}`);

Try / catch

try {
  const data = await client.json(`/sandboxes/${id}`, "GET");
} catch (e) {
  if (e instanceof Error && e.message === "CreateOS returned invalid JSON.") {
    // fetch raw body out-of-band to diagnose (HTML page? empty? truncated?)
  } else throw e;
}

Prevention

When it happens

Trigger: An endpoint that returns 200 with an empty body or plain text; an intermediary (auth proxy, HTML login page, CDN error page) replacing the JSON response while keeping status 200; truncated chunked responses; body actually HTML despite a 200; `object()` rejecting a top-level JSON array/null inside the same try block.

Common situations: Gateway redirect to an HTML sign-in page with 200; provider returning an empty 200 for accepted-but-no-content operations; misrouted apiUrl hitting a different service; body compression/encoding mismatch introduced by a proxy.

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/682d1567dd4a7284. Report an issue: GitHub.

Appendix: source

Thrown at packages/plugins/sandbox-providers/createos/src/client.ts:79

        : path.includes("/stdin/close") ? "stdin close"
        : path.includes("/connect?") ? "output connection"
        : path.endsWith("/processes") ? "process creation"
        : path.includes("/processes/") ? "process cleanup"
        : path.endsWith("/exec") ? "workspace command"
        : "sandbox lifecycle";
      throw new CreateosApiError(response.status, operation);
    }
    return response;
  }

  async json(path: string, method = "GET", body?: unknown, signal?: AbortSignal): Promise<Record<string, unknown>> {
    const response = await this.request(path, {
      method,
      ...(body !== undefined ? { body: JSON.stringify(body), headers: { "Content-Type": "application/json" } } : {}),
      signal,
    });
    let envelope: Record<string, unknown>;
    try { envelope = object(await response.json()); } catch { throw new Error("CreateOS returned invalid JSON."); }
    if (envelope.status !== "success") throw new Error("CreateOS returned an unsuccessful response.");
    return object(envelope.data);
  }

  async getSandbox(id: string, signal?: AbortSignal): Promise<Sandbox> {
    const data = await this.json(`/sandboxes/${identifier(id)}`, "GET", undefined, signal);
    if (data.id !== id || typeof data.status !== "string") throw new Error("CreateOS sandbox identity or state is invalid.");
    return { id, status: data.status };
  }

  async createSandbox(signal: AbortSignal): Promise<Sandbox> {
    const { shape, rootfs, region } = this.config;
    const data = await this.json("/sandboxes", "POST", {
      shape,
      ...(rootfs ? { rootfs } : {}),
      ...(region ? { region } : {}),
      ingress_enabled: false,
      // The host owns lease release. Idle pause is not a command timeout or a

View on GitHub (pinned to 3f1d897a7c)