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
- Fetch the same endpoint with curl and inspect the raw body and Content-Type to see what is actually returned.
- Verify `config.apiUrl` points at the CreateOS API and not a UI/login page or another service.
- Check for proxies/gateways that could inject HTML (auth walls, error interstitials) and exclude the API host from them.
- Ensure the operation is expected to return a body; if an endpoint legitimately returns 204/empty, call it via `request()` instead of `json()`.
- 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
- Exclude the API host from auth proxies/captive portals that return HTML with 200.
- Assert `content-type: application/json` in integration tests against staging.
- Call empty-bodied endpoints via request() directly instead of json().
- Monitor for this error appearing after proxy/CDN config changes.
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.
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
Related errors
- CreateOS returned an invalid response.
- CreateOS returned an invalid resource ID.
- CreateOS returned an unsuccessful response.
- GitHub returned an unreadable webhook configuration…
- returned an unreadable inventory response
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 aView on GitHub (pinned to 3f1d897a7c)