Quivvo REST API
The Quivvo REST API creates and drives machines over HTTPS. The base URL is https://tryquivvo.com/api/v1, it takes and returns JSON, and it's described by an OpenAPI 3.1 document. A field a route doesn't take is ignored, not refused, so check names against that document. The MCP server refuses them.
Auth
Authorization: Bearer qvk_... or X-API-Key: qvk_.... A key belongs to one workspace and carries scopes:
| Scope | Allows |
|---|---|
machines:read |
list and read machines, history, jobs, files (read), ps, ports, usage |
machines:write |
create, exec, files (write), lifecycle, ports, connect, snapshot save, fork, restore |
machines:delete |
delete machines and snapshots, and set or bring forward delete_after_minutes on a machine that already exists |
keys:write |
create and revoke API keys, approve device and agent sign-ins. A key can't create a key with scopes it doesn't have itself |
A read-only key can read every ordinary file on a machine, so whatever anyone saved there, and job logs. It can't read /proc, /sys, /dev, /run or the daemon's own state, even through a link, and gets forbidden: reading can't turn into running. A key with machines:write can run anything as root, so it has no such limit. POST /machines/:id/connect needs machines:write, so a read-only key reads through GET /machines/:id/files.
Keys made by quivvo login (kind cli) get machines:read, machines:write and machines:delete, plus keys:write if the person approving ticks it. Keys an agent gets from login --agent (kind agent)
get the scopes the owner approved, never keys:write. Routes marked session also accept the dashboard's
signed-in cookie. With that cookie, a request that changes something has to come from this site's own pages, or it's forbidden. Billing routes are session-only.
Only a key with machines:write wakes a machine. Reading files or jobs with a read-only key on a machine that isn't running answers machine_not_running, because waking one restarts its bill.
Who am I?
GET /whoami needs a key with any scope, and shows what it may do: kind, key, email, scopes, workspace (id, name, plan, plan_status, credit_balance), limits, exec, machines (total, running) and queue.
The queue
GET /queue needs a key with any scope, and shows your own workspace's place, the same object as queue in whoami:
{ "waiting": true, "position": 412, "invites": 3, "moves_per_invite": 10, "max_counted_invites": 50, "invite_url": "https://tryquivvo.com/login?invite=abcd1234" }
Or { "waiting": false } once it's let in. While it's waiting, a workspace gets queued from anything that creates a machine. Each new mailbox that signs up with invite_url and confirms its email moves it up moves_per_invite places, for up to max_counted_invites of them. Upgrading or topping up lets it in. queue_open in the catalog says whether a new sign-up waits.
Errors
{ "error": { "code": "not_found", "message": "No machine named dev.", "retryable": false, "retry_after_seconds": null,
"docs_url": "https://tryquivvo.com/docs/errors#not_found", "request_id": "req_..." } }
Branch on error.code. When retryable is true, retry after retry_after_seconds. Every response carries an x-request-id header, and request_id repeats it. owner_action, when present, is { url, message }: something only the workspace's owner can do, signed in on the dashboard. Errors lists every code and what to do about it. A path that doesn't exist is a not_found like any other. A path that exists, asked with a method it doesn't take, answers 405 with no body.
Idempotency
Lifecycle POSTs (create, start, stop, suspend, resume, restart, snapshot, fork, restore) accept
Idempotency-Key: <any string up to 200 chars>. Repeating a request with the same key within 24 hours returns
the first response instead of acting again. A workspace keeps its newest 1,000 keys, so a retry of an older one acts again. POST /machines also takes it as idempotency_key in the body. DELETE on a machine or snapshot that's already gone succeeds with already_deleted: true, so retrying is safe. What's deleted is remembered for 35 days, and a workspace's newest 500 deleted machines. After that it's not_found.
Objects
Machine {
"id": "m_8f3k2l9x", "name": "dev",
"state": "creating"|"starting"|"running"|"suspending"|"suspended"|"stopping"|"stopped"|"failed"|"deleted",
"template": "universal", "region": "ams", "vcpus": 2, "memory_mib": 4096, "disk_gib": 10,
"url": "https://...", // public URL for the last port published, null until one is
"auto_suspend_minutes": 30, // 0 = never
"created_at": "...", "started_at": "..."|null, "last_activity_at": "..."|null,
"error": null|"why it failed",
"hourly_cost": 0.054,
"delete_after": null|"...", // set once the owner was warned that this abandoned machine will be deleted
"auto_delete_at": null|"...", // when the server deletes it, from delete_after_minutes
"setup": null|{ "state": "pending"|"running"|"succeeded"|"failed", "exit_code": null|0, "job_id": null|"j_..." },
"suspend_outcome": null|"suspended"|"stopped", // what the last suspend turned into; cleared on the next boot
"resume_outcome": null|"resumed"|"cold_boot", // what the last boot came back as; cleared when it next goes down
"warnings": ["..."] // only when something went differently from what you asked
}
Snapshot { "id": "snap_...", "name": "base", "machine_id": "m_..."|null, "template": "universal",
"disk_gib": 10, "state": "creating"|"ready"|"failed", "created_at": "...", "delete_after": null|"..." }
Job { "id": "j_...", "cmd": "...", "argv": null, "cwd": "...", "pid": 1, "state": "running"|"exited"|"killed"|"lost",
"exit_code": null|0, "started_at": "...", "finished_at": null|"...", "timeout_at": "...", "oom_killed": false }
Exec { "id": "ex_...", "exit_code": 0, "stdout": "...", "stderr": "...", "truncated": false, "timed_out": false,
"oom_killed": false, "duration_ms": 12, "resume_outcome": "resumed"|"cold_boot", "warnings": ["..."] } // these two only when it woke the machine, or something went differently
Port { "port": 3000, "public": true, "default": true, "url": "https://..."|null, "replaced_port": null|8080, "warnings": ["..."] }
:id in a path is a machine id or its name.
- A job's
stateoflostmeans its process went away with its exit status unknown, for example after a reboot.killedwithexit_code124 means it hit its timeout. oom_killedmeans the kernel's out-of-memory killer fired while it ran. That's machine-wide, so it's strongest alongside exit code 137.
Catalog (no auth)
GET /catalog → { "templates": [{name, description, default}], "sizes": [{name, vcpus, memory_mib, default}], "regions": [{code, name, default}], "plans": [...], "rates": {...}, "topups": [...], "exec": {...}, "queue_open": false }
exec holds the command limits: sync_default_seconds and sync_max_seconds (280), job_default_seconds (3600), job_max_seconds (86400).
Machines
| Method & path | Body / query | Returns |
|---|---|---|
GET /machines |
?state=running, ?all=1 to include what's deleted and still remembered |
{ "machines": [Machine] } |
POST /machines |
{ name?, template?, size?, vcpus?, memory_mib?, disk_gib?, region?, snapshot?, setup?, env?, auto_suspend_minutes?, delete_after_minutes?, idempotency_key? } |
201 Machine (state starting; poll or pass ?wait=1 to get it running) |
GET /machines/:id |
Machine |
|
PATCH /machines/:id |
{ name?, auto_suspend_minutes?, vcpus?, memory_mib?, disk_gib?, delete_after_minutes? } (CPU/memory only while stopped; disk grows any time; delete_after_minutes counts from now, and null cancels it) |
Machine |
DELETE /machines/:id |
header X-Confirm-Delete: <id or exact name> |
{ "deleted": true, "already_deleted"?: true } |
POST /machines/:id/start |
?wait=0 to answer before it's ready |
Machine (boots fresh from stopped) |
POST /machines/:id/stop |
Machine |
|
POST /machines/:id/suspend |
Machine, with suspend_outcome |
|
POST /machines/:id/resume |
?wait=0 |
Machine, with resume_outcome |
POST /machines/:id/restart |
?wait=0 |
Machine (power-cycle) |
POST /machines/:id/exec |
{ cmd?, argv?, cwd?, env?, timeout_ms?, stdin?, background?, tail_bytes? } |
Exec; { "job": Job } when background; a stream with Accept: text/event-stream |
GET /machines/:id/jobs |
{ "jobs": [Job] } |
|
GET /machines/:id/jobs/:job |
?wait=<s>, up to 280; ask again if it's still running |
Job |
GET /machines/:id/jobs/:job/logs |
?offset=, ?tail_bytes=, ?follow=1 |
text/plain, X-Next-Offset (not with follow) |
POST /machines/:id/jobs/:job/kill |
{ signal? }: TERM, KILL or INT |
Job |
GET /machines/:id/history |
?limit=, 100 by default |
{ "history": [{ id, cmd, argv, cwd, job_id, exit_code, timed_out, oom_killed, cancelled, started_at, finished_at }] } (works while stopped) |
GET /machines/:id/history/:exec/output |
{ id, job_id, stdout, stderr, truncated, exit_code, timed_out, oom_killed, cancelled } (the first 16 KiB and last 48 KiB of each stream) |
|
GET /machines/:id/ps |
{ "processes": [{ pid, ppid, user, command, rss_bytes, cpu_seconds, started_at }] }. Doesn't wake a machine; answers machine_not_running |
|
GET /machines/:id/status |
What the machine's daemon reports: idle_seconds, sessions, jobs_running, memory, data_disk, oom_kills and more. Doesn't wake a machine; answers machine_not_running if it isn't running |
|
GET /machines/:id/logs |
?lines=, ?format=json |
Boot and daemon logs as text. Never wakes a machine: for any other state it answers with its state, error and the provider's events. JSON: { machine, name, state, error, logs, events, note } |
GET /machines/:id/files |
?path= |
A directory as { path, entries: [{ name, type, size, mode, mtime }] }, or a file's bytes, always as a download and never rendered |
PUT /machines/:id/files |
?path=&mode=, raw body up to 4 MB |
{ path, size } |
DELETE /machines/:id/files |
?path=&recursive=1 |
{ "deleted": true } |
POST /machines/:id/connect |
{ url, token, expires_at, machine }, a 15-minute token for the daemon inside the machine (terminal, TCP tunnels, large files). The CLI is the supported client for what it opens, and the routes behind it can change. Resumes or starts the machine if needed. Connections already open stay open. The CLI uses this for ssh, cp, push and port-forward, and they reach it at *.fly.dev |
|
GET /machines/:id/ports |
{ "ports": [Port] } |
|
POST /machines/:id/ports |
{ port, public? = true } |
Port (url carries ?box_token= when private, good for 7 days. A key that can only read, and a webhook, get url: null for a private port). A machine can have 10, and an 11th is limit_reached |
DELETE /machines/:id/ports/:port |
{ "deleted": true }. If the URL served that port, it now serves the highest one left |
|
POST /machines/:id/snapshots |
{ name } |
201 Snapshot (creating if the copy isn't whole when the request answers; usable once ready) |
POST /machines/:id/restore |
{ snapshot } |
Machine (puts the snapshot's disk back; same id, name, URL). limit_reached means nothing changed. If something takes the running slot while it works, the disk is restored and the answer is the machine, stopped, with warnings |
POST /machines/:id/fork |
{ count? = 1, name? }, up to 10 |
{ "machines": [Machine], "failed": [{ name, error }] } (an error only if none could be made) |
Machines auto-resume when you exec, connect, or touch files or jobs on a suspended one, and auto-start when stopped. That restarts their bill, and so the answer says so: resume_outcome and warnings on an exec or connect (on the exit event when streaming), and an X-Quivvo-Woke: resumed or cold_boot header on files and jobs.
Suspend keeps memory and processes when the provider can, and otherwise stops the machine, which keeps only the disk. Both are a success. See what suspend does.
A machine's url serves one port, the last published, and answers only while the machine runs: a request to it doesn't wake a suspended machine.
Running commands
cmdruns withbash -lc, andargvruns directly. Pass exactly one.cwddefaults to/data/home.- Without
background,timeout_msis 280000 by default and at most. A command that hits it exits 124 withtimed_outtrue. If waking the machine used up part of the request, the command gets less, andwarningssays so. A waiting command whose caller goes away gets SIGTERM, then SIGKILL 3 seconds later, and its history entry hascancelledtrue. - With
background,timeout_msis an hour by default and a day at most, and the job goes on if you disconnect.stdincan't be combined with it. stdinis text, up to 1 MB. For anything that isn't UTF-8, sendstdin_base64instead.stdoutandstderrkeep at most 1 MiB each: the first 256 KiB and the last 768 KiB, joined by a line saying how many bytes were left out, withtruncatedtrue.tail_bytes, up to 1 MiB, keeps only the end.cmd, orargvin total, is limited to 128 KiB. Put anything longer in a file on the machine.- Pass secrets in
env. Command text is kept in history, andenvisn't. stdoutandstderrare text, so output that isn't UTF-8 comes back with replacement characters, andwarningssays so. For binary data use the files API, or the stream below.- History keeps a machine's newest 100 commands for 30 days, with the start of any command over 8 KiB, and goes when the machine is deleted. A workspace keeps the newest 8 MiB of it across its machines. A workspace can run 600 commands a minute through the API and make 1,200 changes of any kind, and past that gets
rate_limited. So does creating more machines in an hour than 60 for each machine its plan runs at once: 60 on Free.
With Accept: text/event-stream you get Server-Sent Events with no output cap:
event: stdout
data: {"data":"partial text"}
event: stderr
data: {"data":"..."}
event: exit
data: {"id":"ex_...","exit_code":0,"timed_out":false,"oom_killed":false,"duration_ms":812}
A chunk that isn't valid UTF-8 has replacement characters in data and the bytes the command wrote in data_base64. A comment line : ping keeps the stream open. GET .../logs?follow=1 streams until the job ends or for about 280 seconds. If it's still running, follow on from the bytes you've got so far, with offset.
When is a machine deleted?
Abandoned machines and snapshots are deleted, after an email warning and a final reminder 2 days before. At zero credit everything goes 7 days after the warning (30 if the workspace has ever paid). On the welcome credit alone, a machine not used for 14 days goes 7 days after the warning, and so do snapshots once no machine in the workspace has been used for that long. Adding credit, resuming the machine or starting a machine from the snapshot cancels it. Active paid plans are exempt. Without a working email sender, nothing is deleted.
Snapshots
| Method & path | Body | Returns |
|---|---|---|
GET /snapshots |
{ "snapshots": [Snapshot] } |
|
DELETE /snapshots/:id |
header X-Confirm-Delete |
{ "deleted": true, "already_deleted"?: true } |
Create a machine from one with POST /machines { "snapshot": "base" }.
Usage and billing
GET /usage→{ "period_start", "period_end", "plan", "credit_balance", "cost_this_period", "machines": [{ id, name, deleted, running_seconds, cost }], "days": [{ date, cost }], "rates": {...} }. Amounts are US dollars, to 8 decimal places.GET /billing(session) → plan, balance, ledger.POST /billing/checkout(session){ kind: "plan", plan } | { kind: "topup", amount }→{ url }.
Keys (keys:write or session)
GET /keys→{ "keys": [{ id, name, kind, prefix, scopes, created_at, last_used_at }] }POST /keys{ name, scopes? }→{ "key": {...}, "api_key": "qvk_..." }(the secret is shown once)DELETE /keys/:id→{ "deleted": true }. Any key can revoke itself, with or withoutkeys:write.
Device sign-in (CLI and agents)
POST /auth/device{ "kind": "cli"|"agent", "name": "agent@host", "scopes"?: [...] }(no auth) →{ "device_code", "user_code": "ABCD-EFGH", "verification_url", "verification_url_complete", "expires_in", "interval" }.clicodes live 15 minutes,agentcodes 24 hours.- The owner opens
verification_url_completesigned in and approves, unticking any scopes, or runsquivvo claim ABCD-EFGH(needskeys:write), which callsPOST /auth/device/approve { user_code, scopes? }. Withoutscopes, it grants what the sign-in asked for exceptkeys:write, which has to be asked for by name. A key can't grant a scope it doesn't have.POST /auth/device/deny { user_code }refuses it.GET /auth/device/request?code=shows what's being approved, withcreated_atandstarted_from. A workspace can make 120 of these three calls an hour. - The device polls
POST /auth/device/token { device_code }everyintervalseconds:400 { error: { code: "authorization_pending" } },400 access_denied,400 expired_token, or200 { "api_key": "qvk_...", "key": {...}, "workspace": { id, name }, "queue": {...} }. Asking again within 10 minutes, if that answer got lost, returns a new key and revokes the first. That stops working once the key has been used or revoked, and a sign-in only ever holds one live key.
See agent sign-up for how an agent does this without blocking.
Webhooks
GET /webhooks,POST /webhooks { url, events? }→201 { webhook, secret },DELETE /webhooks/:id.urlmust be publichttps. Private, loopback and link-local addresses, internal names and a username or password in the URL are refused, and a name that resolves to a private address isn't delivered to. Redirects aren't followed.- Events:
machine.ready,machine.suspended,machine.stopped,machine.failed,machine.deleted. POSTto your URL with{ "id", "type", "created_at", "data": { "machine": Machine } }and headerQuivvo-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + body)>.
Feedback
POST /feedback { message, machine?, context? } → 201 { id, received: true }. message is up to 4000 bytes, context is any JSON up to 16 KiB, and a workspace can send 30 an hour. It keeps its newest 1 MiB of them, for 90 days. Any valid key can use it. Include a request_id if an error gave you one, and leave out secrets.
MCP
Streamable HTTP at https://tryquivvo.com/api/mcp. Clients sign in with OAuth at the first tool call, or send Authorization: Bearer qvk_.... See MCP server for its tools.
Give your agent a computer
npx quivvo@latest sh