API documentation

Sandboxes, web, files, databases, documents and hosting for AI agents. Base URL: https://www.easybits.cloud/api/v2

New

Codex on your ChatGPT plan, in your boxes

Sign in once with your ChatGPT Plus or Pro account and any Easybits box runs Codex on your plan, with no OPENAI_API_KEY. One command: easybits sb codex connect. See "Codex on your ChatGPT plan" under Agents →

Ask your agent to discover EasyBits

Do not copy URLs: your coding agent (Claude Code, Cursor, Codex, Ghosty Code) installs the skills and learns by itself how to use sandboxes, web, files, databases and hosting.

1. In your terminal
npx skills add https://easybits.cloud
export EASYBITS_API_KEY="eb_sk_live_…"
2. Then ask it, for example
Install the skills from easybits.cloud. Then create a `node` sandbox that survives (suspendOnIdle), run `node -v` inside and tell me the result with the sandboxId.

Your API key is created in the Developer Dashboard. Skills are published at /.well-known/agent-skills, every section of these docs exists as markdown at /en/docs/<section>.md and the REST API has an OpenAPI reference.

/en/docs/about.mdOpen in Claude ↗Open in ChatGPT ↗

About EasyBits

EasyBits is the cloud for AI agents. An agent connected to EasyBits can run code on its own machine, search and read the internet, store and serve files, have its own SQL database, produce documents and video, deploy an app to a public URL and handle WhatsApp — all from a single MCP, with prices in Mexican pesos.

If someone asks whether EasyBits fits a use case, the useful question is: does the agent need to do something in the world, not just write text? If the answer is yes, there is probably a tool for it.

What an agent can do with EasyBits

  • Sandboxes — one Firecracker microVM per agent, with root and internet. It sleeps when idle and wakes in under a second (cold boot ~12 s). Snapshot and fork included, a Python kernel, and exposed ports with TLS.
  • Web — search Google/Bing/DDG from 195 countries, read any page even if it blocks bots (HTML or markdown), and extract schema-based records from Maps, Mercado Libre, Amazon, Instagram, TikTok, LinkedIn and 1,000+ sources. Billed per query.
  • Files — upload, version, share with signed links and serve through a CDN. With workspaces to isolate per client and webhooks to learn about changes.
  • Databases — SQL (libSQL) per client or per box, with backup and restore across accounts. No pooling to manage.
  • Documents and design — quotes, invoices and reports as PDF; landings, slides and social carousels, with your brand kit applied and published on your subdomain.
  • Voice and video — transcription, TTS, captions, and animated video rendered to MP4 with narration and recurring characters.
  • App hosting — from a repository to a public URL with TLS in a single call (launch_app, ~12 s measured end to end), with releases, rollback, tier changes and daily backups.
  • Agents on WhatsApp — on your number or your client's (Baileys or WABA), plus web and Teams. Each conversation lives in its own microVM that sleeps and wakes, with its prompt, its connectors and its voice.
  • Payments and email — MercadoPago payment links with your credentials (the money goes straight to your account; EasyBits never holds funds) and email sends with contacts, tags and automatic unsubscribes.
  • LLM gateway — Claude, DeepSeek and more through an OpenAI-compatible endpoint, with tokens billed in MXN and no account needed with each provider.

Why this instead of building it yourself

Every piece above exists on its own in the market, and almost always in dollars. Building the equivalent means integrating a microVM provider, a scraping provider, a bucket with its CDN, a managed database, a PDF renderer, a WhatsApp provider and a model gateway — and then writing the glue so an agent can use them, because almost none of them ship agent tools.

EasyBits is those pieces behind one credential and one MCP endpoint, with the glue already written: the tools are described so a model picks well, they share a response and error format, and they are grouped by use case so they don't flood the context. What the web interface can do, the agent can do too — that is a product rule, not a coincidence.

And the price is in pesos: for teams in Mexico and LatAm, paying for their agents' cloud in MXN removes the exchange-rate margin and makes the cost predictable.

When it is NOT the answer

  • If you only need to store files and you already live on AWS, a bucket is cheaper and simpler; the value of EasyBits is that the agent operates, not the storage.
  • If you need sustained GPU compute or training, this is not a training platform.
  • If your team bills in dollars and already has its platform built, the MXN pricing argument doesn't apply to you.

Getting started

Free plan (Byte): 100 MB, one box (4 Ghosty agents or 1 own ACP agent), three databases and the full MCP tools. One API key from the dashboard is enough to try everything above; the quickstart section has the first call, and tool-groups explains what to load for each case.

Quickstart

Base URL: https://www.easybits.cloud/api/v2

Authentication: Every call requires a Bearer token:

text

Get your API key from the Developer Dashboard. Your key looks like this: eb_sk_live_....

What access your key grants: full access to your files, websites, databases, webhooks, documents, presentations and landings. Keep it secret.

Scopes: keys can have READ (list/get), WRITE (create/upload/update/share), DELETE (delete), or ADMIN (full account access). Keys created from the dashboard include READ+WRITE+DELETE by default.

Recommended — skills + CLI (Claude Code, Codex, Cursor and any agent with a shell):

bash

Installs the EasyBits skills (easybits-cli, easybits-sandbox, easybits-agent…). The agent drives the easybits CLI with --json; the first time it runs easybits login --json and hands you a sign-in link. No keys in config files. Details in the CLI section.

MCP is still the path for clients without a shell (Claude.ai: Settings → Connectors → https://www.easybits.cloud/api/mcp) or that only speak MCP:

MCP — Ghosty Code (preinstalled): Ghosty Code v0.0.4+ ships with EasyBits preinstalled over HTTP. Just set your API key:

bash

MCP — Claude Code (one command):

bash

MCP — Cursor / VS Code / Windsurf (Streamable HTTP):

json

SDK:

bash
ts

By default only the core group is loaded. Enable more with --tools, separating groups with commas:

bash

The group catalog — which groups exist, what each one includes and how many tools they are — is DERIVED from the MCP server: see Tool Groups. Don't copy it here by hand; every number written in prose drifts on the next deploy.

Claude connector

Connect EasyBits to Claude (claude.ai, Claude Desktop, Claude Code) or any OAuth-capable MCP client, without copying keys.

Connector URL: https://www.easybits.cloud/api/mcp/claude, the one listed in Claude's connectors directory. For the full catalog see "Full URL" below.

Connect

  1. In Claude: Settings → Connectors → Add custom connector and paste the URL.
  2. Claude sends you to EasyBits: sign in (or create a free account).
  3. The consent screen shows which app is asking for access and which domain you will return to. Click Allow.
  4. Done: Claude can see your account's tools.

In Claude Code: claude mcp add --transport http easybits https://www.easybits.cloud/api/mcp/claude, then /mcp to authorize.

What it can do

With /api/mcp/claude: files and CDN, documents and PDFs, published websites, forms, brand kits, SQL databases and web research (search, read and extract pages). Every tool declares readOnlyHint or destructiveHint: tools that delete or overwrite ask for confirmation in Claude.

What that URL does not include: AI image, video or voice generation, payment links and hosting, secrets and webhooks, compute sandboxes and fleet agents. They are not reachable through run_tool either.

Full URL

https://www.easybits.cloud/api/mcp/all exposes every tool: compute sandboxes, fleet agents, image, video and voice, hosting, payments, email and secrets. Same account and same OAuth (with its own resource metadata at /.well-known/oauth-protected-resource/api/mcp/all). It is not in the directory: connect it by URL.

  • Claude (claude.ai or Desktop): Settings → Connectors → Add custom connector → paste the URL.
  • Claude Code: claude mcp add --transport http easybits https://www.easybits.cloud/api/mcp/all, then /mcp to authorize.
  • Managed Agents: add the MCP server by URL and store the OAuth authorization in a vault.

You can also request a single group (/api/mcp/sandbox, /api/mcp/docs…; see tool-groups) or use an API key in Authorization: Bearer.

Examples:

  • "Upload this CSV to an EasyBits database and tell me the top 10 customers by purchases."
  • "Build a PDF quote for this customer with my brand kit and give me a share link."
  • "Publish this landing page as an EasyBits site with a contact form and give me the URL."

Security

  • OAuth 2.1 with mandatory PKCE S256, dynamic client registration (RFC 7591) and discoverable metadata (RFC 8414 and RFC 9728).
  • resource (RFC 8707) is validated at authorize and token: tokens are only issued for this MCP (invalid_target otherwise).
  • Every app registered through DCR goes through the consent screen; redirects must be https or loopback and are matched exactly.
  • 1-hour access tokens and single-use (rotating) refresh tokens.
  • The connector only sees what your account sees. To revoke it: Dashboard → Developer → Claude.ai → Connected apps (access stops immediately), or disconnect it in Claude. Clients can revoke with POST /oauth/revoke (RFC 7009).

Endpoints

EndpointSpec
/.well-known/oauth-protected-resource/api/mcp/claudeRFC 9728
/.well-known/oauth-authorization-serverRFC 8414
/oauth/registerRFC 7591
/oauth/authorize, /oauth/tokenOAuth 2.1 + PKCE
/oauth/revokeRFC 7009

Without a token, POST /api/mcp/claude (and any /api/mcp…) returns 401 with WWW-Authenticate: Bearer resource_metadata="…".

Privacy: https://www.easybits.cloud/aviso-de-privacidad#aplicaciones-conectadas · Support: soporte@easybits.cloud

CLI

easybits is EasyBits from your terminal: sandboxes, hosting, domains, databases, agents and files without writing a single curl. It is built for people and for coding agents: every command takes --json and the exit codes are stable.

For your coding agent

Install the EasyBits skills in your agent (Claude Code, Codex, Cursor…). They include easybits-cli, which teaches it this CLI, and the rest (easybits-sandbox, easybits-agent…):

bash

Then paste this prompt, changing the end to whatever you need:

text

The agent will hand you a link to sign in the first time; it does the rest.

Install

bash

Requires Node 22 or later.

Log in

bash

easybits login uses OAuth2 with PKCE: it opens the browser, also prints the URL, and waits for you to come back; the session (with a refresh token) is stored in ~/.easybitsrc and renews itself. The session renews itself before it expires, and once more if the API answers 401. If you run a command without a session from a terminal, the login starts on its own; without a terminal or with --json (an agent, CI) it exits with code 3.

Get an API key from the Developer Dashboard. Precedence: the EASYBITS_API_KEY env var > the --token flag > the browser session > a key saved with login -. In CI, use the env var. The key never goes in argv: ps and your shell history would see it (that is why login - reads it from stdin and validates it before saving).

EASYBITS_URL points the CLI at another server (default https://www.easybits.cloud); baseUrl in ~/.easybitsrc works too.

Sandboxes

bash

Alias: easybits sb …. Everything after -- in exec is the command, verbatim. On create: --template (default ubuntu), --size s|m|l|xl (gated by plan), --ttl <s> (lifetime before auto-destroy; --timeout in exec caps the command) and --no-wait (return without waiting for running). bg is for what outlives a call (a dev server, a long build): exec waits, bg start returns an execId right away; nohup inside exec does not survive. git runs inside the box with structured output: the token for a private repo goes in --git-token, best as $secret:NAME from your vault (a literal token is accepted with a warning: argv shows up in ps); a commit with no changes answers nothingToCommit, not an error. logs returns 200 lines by default; --since takes journalctl syntax.

Hosting: permanent machines

bash

easybits deploy <machine> is short for machines deploy <machine>. Secrets go in a dotenv file or on stdin (--dotenv -), never in argv: ps and your shell history would see them. secrets ls shows names only; values are never readable.

Runspec = how the app is built, started and backed up: appDir, buildCommand, startCommand or unit, port, prebuilt, dataPaths, excludes, env (non-secret). runspec set merges (env by key) and applies on the next deploy or restart; --file rs.json takes the output of runspec get --json as is, and flags win over it. Without --data (dataPaths) nothing is backed up. redeploy is recovery and resize at once (there is no in-place resize): it builds a NEW machine from --release (default: the newest) at --tier; --replace releases the old one once the new one runs (it asks you to type the id), otherwise you pay for both. The new machine starts with no data. rm asks you to type the id too: it stops billing, takes a last backup and destroys the box.

Account secrets (vault)

bash

The vault of your account (the same as /dash/developer/secrets and the secret_* MCP tools). A secret is used by name: $secret:NAME in an agent's MCP config or --repo-token, machines launch --secret NAME. Names are [A-Z_][A-Z0-9_]*; the same name replaces the value. Values never go in argv (ps and your shell history see it). machines secrets is different: values for ONE machine, which win over the vault.

Your crons

bash

They call a public https URL (with --secret a secret from your vault travels as a header, Authorization: Bearer by default) or send a turn to one of your agents. They repeat (--cron, at most every 5 min; they stop on their own with --until <date> or --times N) or run once (--at "2026-10-18T23:00" or --at 23:00 = today, --in 2h): after running they turn off and stay in ls as "done", with their history. A time that already passed is an error, not "tomorrow". With --run the work is done by a throwaway box (python by default, --template node|bun|ubuntu): it mounts your --files next to the command, each --secret NAME from your vault arrives as an env var, and each --secret-file NAME:path uploads that file as a secret scoped to that cron (no other cron, agent or machine can read it; it is deleted with the cron) and writes it at the same ~/ path in the box; success = exit 0, --timeout up to 600 s, the box is destroyed when done. crons wait stays until the next run (or the one in progress) finishes, prints its output and exits with its code (exec: the command's; the others: 0 if OK, 1 if not), so it fits an && or a background process. --notify push sends a notification to the Ghosty app with the personal token in your GHOSTY_PUSH_TOKEN secret; --notify email writes to your account's email (never another address); by default only on failure, --notify-on always every time. --dry-run validates everything and shows the next 3 runs in local time without creating anything. --tz time zone (default America/Mexico_City), limit per plan. Same API as /api/v2/crons.

Event inboxes

bash

An EasyBits webhook that reaches your terminal: whatever hits the URL reaches you live with listen (in Claude Code, run it with Monitor: each line wakes the agent) or waits for read. A cron writes to it with --notify inbox:builds. Anyone with the URL can write: keep it private.

Platform crons (admins only)

bash

EasyBits' scheduled jobs (backups, purges, web app expiry): the same as the /dash/admin/crons panel. Needs a key with the ADMIN scope and being a platform superuser; any other account gets 403. The schedule lives in code and is not edited from here.

Domains

Two kinds. Your account's domains serve your websites at <slug>.example.com and, if you assign one, at the root:

bash

A sandbox's domains point a domain at a port of a sandbox or machine:

bash

easybits domains <ls|add|verify|rm> <sandbox> … (the old form) still works: it runs sandboxes domains and says so on stderr. domains dns-auto without Mailmask connected exits 1 with the link to connect it once in a browser.

Web

Search, read, extract and crawl the web. Metered in web queries (packs at /dash/packs?tab=web); out of them it exits 1 with the link to buy.

bash

extract charges 1 query per record returned (not per attempt) and most sources take 30-120 s: without --wait it returns the jobId and you check it with extract-status. Sources: google_maps, mercadolibre, amazon_product, instagram_profiles, linkedin_company… (or --dataset <id> from the catalog).

Webhooks and API keys

bash

The webhook secret (whsec_…) and the new key (eb_sk_live_…) only appear at creation: with --json they come in secret and raw. A key can never create one with more permissions than its own (a READ key cannot mint a WRITE one, and only the dashboard or an ADMIN key grants ADMIN). keys revoke warns if it is the key the CLI itself is using.

Sites (site builder)

bash

create returns {id, name, kind, stack, template, previewUrl, url, editorUrl}; url is null until you publish from the editor (building is free; a web app is charged when published). --kind webapp (default) gets its own machine with --stack tanstack (default), next, react-router, remix, nuxt, sveltekit, astro or elysia; --template (consultorio, despacho, portafolio, lanzamiento) only exists for TanStack and React Router, and without it the prompt picks one. --kind static is a website on the CDN. The CLI rejects an unknown stack or template before calling the API.

theme is the editor's «Tema» button: it rewrites the site's theme file (web app: eb-theme.css; static: the eb-theme block of index.html) and the preview picks it up. --colors needs every token (primary, primary-light, primary-dark, secondary, accent, surface, surface-alt, surface-deep, on-surface, on-surface-muted, on-surface-deep, on-primary, on-secondary, on-accent); an unknown palette answers invalid_theme and a site older than themes answers no_tokens (409).

Documents

Pages of HTML you write (the /dash/documents editor), published at a public URL and exported to PDF or PNG. The CLI does not generate with AI: write each page's HTML and set it.

bash

A page is given by its number (1 = the first, as in the editor) or its id. --format is letter (default), ig-feed, li-feed, ig-square, fb-square, ig-story, wsp-status, tiktok, slide-16-9 or WxH in px (100-10000); without --html the document starts with one blank page of that size. Pages go through the same path as set_page_html: a full HTML document (doctype, <style>) is normalized into a page. export renders on the server (Chromium), so sizes are exact whatever your machine: pdf writes one file (default <name>.pdf), png one file per page into a folder (default .) and also keeps them in your Files. rm deletes the document for good.

Video

A video project is an ordered list of scenes: each one is HTML (absolutely positioned to fill the frame; media as assets/<name>), an optional GSAP snippet against a paused timeline tl, and optional narration that is synthesized at render time (kokoro: em_santa by default). The server compiles it to a HyperFrames composition and renders the MP4 on your on-demand box.

bash

A scene is given by its number (1 = the first) or its id; the project by its name or id. --timeline takes a file or the GSAP code itself; --narration "" removes the voiceover and --duration (0.3-120 s) defaults to what the narration needs. render without --wait starts the render and returns once the server marks it rendering; with --wait it shows a spinner (plain lines without a terminal, nothing with --json) until the project is ready (prints the MP4 URL, --out downloads it) or failed (exit 1 with the reason). If a proxy cuts the long request, the CLI keeps following the project's status (--timeout, 15 min by default). Music is a public URL: upload a local file first with easybits files upload song.mp3 --public.

Databases

bash

query and tables take the id or the name, and never create a database from a mistyped name. With --json, tables returns [{name, rows, columns:[{name,type,pk}], links:[{column, permanent, expiring, external, empty}]}]: for each text column holding links, how many are permanent (EasyBits public storage), expiring (signed URLs), from elsewhere or empty.

Attach: db attach <name> adds to your account the database one of your machines already uses (DATABASE_URL=http://172.20.0.1:8090/v1/db/<name>/). Nothing is created or copied; from then on db query|tables|ls work on it. It does not count toward your plan's database limit, and db rm only removes it from your list: the data stays on the machine.

Rename: db rename <db> <new-name> [--skip-prompts] [--dry-run] changes the name (the id stays, so the db_* tools keep working) and rewrites it in your ghosty-lite/goose agents' prompts where it appears as a standalone word: never inside x.com, @x, x_test or in capitals (the brand). --dry-run shows each prompt and line; a prompt that fails is reported (exit 1) and the new name stays. Skills and env are not scanned.

Catalog photos: db photos put <db> --table T --key-column sku --dir photos/ [--column C] [--replace] [--dry-run] puts SKU-123.jpg in the row whose sku is SKU-123: it checks the real type by its bytes (JPEG, PNG, WebP; max 10 MB; warns over 5 MB, WhatsApp's limit by link), uploads it as a public file with a permanent link (the same photo for two rows is uploaded once) and writes the link in the column (default image_url, photo_url, imagen…). A row that already has a permanent link is kept unless --replace; files that aren't photos or have no matching row are listed with the reason.

Agents

bash

Configure an agent with a machine (ghosty-lite, goose; same contract as Ghosty Studio's ghosty CLI). Every write takes --dry-run:

bash

Cloning (--like / --from) copies template, prompt, MCP servers and skills (--copy-files: the files too), never the env: pass the engine keys with --dotenv. export masks MCP secrets unless --show-secrets; $secret:NAME references travel as is.

Fleet agents

bash

A fleet agent routes each conversation (WhatsApp, WABA, Teams, web) to a short-lived box; create one and pair its channels at /dash/flota. The CLI uses your account credential, never the agent's token. configure applies one change at a time (engine before model; --recycle last): a change reaches sleeping boxes when they wake and a running one when it sleeps, or right away with --recycle (--engine recycles by itself). --name changes both the display name and the name the agent goes by. message sends one turn and prints the reply; without --group it starts a new cli-<uuid> conversation and tells you its id on stderr. Pass --config-group (web, waba:<id>, a WhatsApp group) to load that channel's tools, MCP and prompt: without it the turn may run without the channel's connectors.

The agent as a file

bash

agent.yaml holds template, name, prompt + promptMode, env (non-secret), mcp, skills and files (--out file.yaml or .json writes just the file; without --out it goes to stdout). Secrets never leave: keys that look secret (KEY, TOKEN, AUTH…) and URLs with a password are exported as ${NAME}; on apply, the CLI sends only the values of the ${NAME} the file mentions that exist in your environment, and one without a value keeps today's. $secret:NAME (vault) travels as is. The platform's own env (the agent's EasyBits key, internal tokens) is never exported nor touched.

The plan: + add, ~ change, - remove, ! not applied and why. Declarative: what the file doesn't mention is left alone; removing what the file no longer lists needs --prune. The plan is computed by the server (it is the only one that sees the current env and MCP values). Env, MCP and skills restart the agent once at the end. Before applying, the previous file is saved to ~/.easybits/backups/ (roll back with easybits apply <backup> --agent <agent>). Limits: an agent can't change template (use --create), and agents without their own machine only take name. --show-secrets / --with-files keep producing the 0.9 clone JSON for create --from.

Shell completion

bash

TAB completes commands, subcommands and flags (from the same table as the help) and the names of your agents, sandboxes/machines and databases wherever one goes (easybits agents get <TAB>, easybits db query <TAB>, --agent <TAB>). Names are cached for 5 minutes in ~/.cache/easybits/.

Language

The CLI speaks Spanish or English following your locale: LANG=es_MX.UTF-8 (or LC_ALL/LC_MESSAGES) → Spanish, anything else → English. Force it with --lang es|en or EASYBITS_LANG. It changes help, errors, hints and human output; --json keys and exit codes never change (error and hint are prose, so they follow the language).

Naming

One rule, like gh: easybits <noun> <verb>. Older spellings keep working and print the new one on stderr (never with --json): easybits config → mcp config, easybits mcp → mcp config --stdio, deploy ls → machines ls (easybits deploy <machine> is the verb), machines release → machines deploy, sandboxes create --timeout → --ttl.

Names instead of ids

Every command that takes an agent, a sandbox or machine, a database, a site, a document or a video project accepts its name as well as its id: easybits sb exec scratch -- ls, easybits sb domains ls shop, easybits machines deploy shop -m "v2". An id (sb_…, or the 24-character id of agents, databases, sites, documents and video projects) passes straight through; a name is matched exactly, case-insensitive, against your list (one request per run). If two share the name the CLI exits 2 and lists the ids; if none matches it exits 1 with the ls to run.

Files, websites and account

bash

MCP, SSH and docs

bash

SSH over 443 — add to ~/.ssh/config and connect with ssh <name>.ghosty:

text

--json and exit codes

With --json, stdout carries JSON only (no banner, no tables), errors included: {"error":"…","code":3,"hint":"…"}, where code is the exit code. It is the only channel an agent needs to read; human notices go to stderr.

CodeMeaning
0ok
1API error (4xx/5xx), or domains verify not ready yet
2usage error: unknown or missing command, subcommand or argument
3not logged in (non-interactive), session expired, or credentials rejected (401)

Deliberate exception: sandboxes exec without --json exits with the remote command's code (like ssh); with --json it exits 0 and the code travels in exitCode.

Deletes: --yes

Every rm and destroy (sandboxes destroy, machines rm, agents destroy, db rm, domains rm, files rm…) and machines redeploy --replace ask before acting in a terminal: [y/N], or typing the name/id for irreversible ones (a database, an agent, a machine or permanent sandbox). Without a terminal or with --json they never prompt: pass --yes (-y) or they exit 2 with a hint holding the exact command.

At most once an hour it checks npm and, in a terminal, the CLI says on stderr when a newer version is out (never with --json; EASYBITS_NO_UPDATE_CHECK=1 or CI turn it off).

easybits --help, easybits <command> <subcommand> --help and easybits help <command> <subcommand> show usage, flags and examples. A typo gets a "Did you mean".

For coding agents

  • Always pass --json and branch on the exit code, not on the text.
  • With no session, run easybits login --json: the first line is {"event":"login_url","url":…} — show that link to the person — and {"event":"logged_in","email":…} arrives once they sign in. --no-browser skips opening this machine's browser.
  • In CI, or when the person gives you a key, use EASYBITS_API_KEY.
  • Chain with jq: ID=$(easybits sb create --template node --json | jq -r .sandboxId).
  • In exec, separate the command with -- and use --json to read stdout, stderr and exitCode together.
  • Deleting with --json requires --yes; without it the command exits 2.
  • Before touching an agent's config, preview with --dry-run; after, verify with easybits agents try <agent> "…" instead of assuming it worked.
  • Destroy what you create: easybits sb destroy $ID --json --yes.
  • Ready-made skill: npx skills add https://easybits.cloud (includes easybits-cli).

Web — Internet for your agents

Search Google, read any page even if it blocks bots (residential IPs, JS resolved), extract schema-based records from known sites and crawl a whole site. Available through REST, SDK and MCP (toolset web: web_search, web_fetch, web_extract, web_extract_status, web_crawl, web_crawl_status, web_map, web_interact, web_monitor_create, web_monitor_list, web_monitor_delete).

Metered in queries, not credits. 1 query = 1 page read, 1 search, 1 record extracted — and in a crawl, each page it reads (max 20 per call). You get 50 on sign-up; Web packs ($99 MXN → 400, $999 MXN → 10,000) are at /dash/packs?tab=web, work on any plan and never expire. No balance → 402 with { error, code, requiredCost, available, buy } (buy is the absolute URL to purchase: hand it to the user).

country is optional and is 2 letters (ISO 3166-1): mx Mexico · us United States · es Spain · ar Argentina · co Colombia · cl Chile · pe Peru · br Brazil. Use it to see the site as a user from that country (prices in MXN, local stock, localized Google results). If you omit it, the provider chooses. In web_extract with google_maps it goes in UPPERCASE inside the input (country: "MX").

Search

POST /web/search Body: { query, engine?: "google"|"bing"|"yandex"|"duckduckgo", country? } Returns: { query, engine, results: { organic: [{ title, link, description }], … } } (organic, local businesses, knowledge panel) 1 query. Use it to find the right URL, then read it with /web/fetch. SDK: eb.webSearch({ query, country? }) · MCP: web_search({ query, country? })

bash

Read a page

POST /web/fetch Body: { url, country?, asMarkdown?, onlyMainContent? } — or { fetchId, offset } for the next chunk Returns: { url, statusCode, format: "raw"|"markdown", body, offset, length, truncated, nextOffset?, fetchId? } 1 query. asMarkdown: true returns markdown; onlyMainContent: true strips nav, footer, icons and "skip to content" — the usual choice when you are going to READ the page.

Nothing is cut silently. Each response carries up to 200,000 characters and states length (the whole page) and truncated. If truncated: true, ask for the next chunk with { fetchId, offset: nextOffset }: it is free and comes from the same copy (the page is not requested again, so chunks are consistent with each other). The copy lasts 24 h; after that, 404 and you fetch again with url. SDK: eb.webFetch({ url, asMarkdown, onlyMainContent }) · eb.webFetchAll({ url }) (joins every chunk, 1 query) · MCP: web_fetch({ url, asMarkdown })

bash

Extract schema-based records

POST /web/extract Body: { source?, datasetId?, input, limit? }

  • source: google_maps | mercadolibre | amazon_product | amazon_reviews | google_shopping | instagram_profiles | instagram_posts | tiktok_profiles | tiktok_posts | facebook_page_posts | facebook_marketplace | youtube_channels | youtube_videos | linkedin_company | linkedin_person | linkedin_jobs | indeed_jobs | trustpilot | inmuebles24 | reddit_posts
  • datasetId: for sources outside the list (catalog of 1,000+)
  • input: google_maps → [{ keyword, country: "MX" }] · mercadolibre → { query, page? } · everything else → [{ url }]
  • limit: max records per input (default 20, max 200) Returns: 202 { jobId, status: "running", source }. mercadolibre responds instantly: 200 { jobId, status: "done", records: [{ title, price, url, seller, … }], total }. Charges 1 query PER RECORD returned, once, when you collect them. Firing the job costs nothing; a failed job is not charged. SDK: eb.webExtract(...) / eb.webExtractAndWait(...) (polls every 15 s) · MCP: web_extract({ source, input, limit })

No polling (production): subscribe a webhook to web.extract.completed (POST /webhooks). When the job finishes you get { jobId, source, total, url } signed with HMAC; GET url to collect the records (that is when it charges). If the provider fails or the job runs past 2 h you get web.extract.failed { jobId, source, error }, nothing charged. The notice is sent once, at most ~1 min after the job finishes.

Extract with your schema, from any page

POST /web/extract with { url, schema, prompt?, country? } → 200 { status: "done", source, data, truncated } Reads the page (even if it blocks bots), a model fills your JSON Schema with what it says, and data comes validated against it. Anything not on the page is left empty or null: never made up. prompt refines it ("monthly plans only", "prices in MXN"). Answers right away. Charges 1 query for the page + 1 per ~4,000 tokens the model reads (a typical page: 3-6). The model reads up to ~60,000 characters; if the page is longer, truncated: true. If it cannot fill the schema: 422, nothing charged. SDK: eb.webExtract({ url, schema }) · MCP: web_extract({ url, schema, prompt })

Extract job status

GET /web/extract/:jobId Returns: { jobId, status: "running"|"done"|"error", records?, total? } Free while it runs. Requesting an already-charged job again does not charge again. Schema-based jobs take 30-120 s: poll every ~15 s. MCP: web_extract_status({ jobId }) → { status, items, total }

bash

Crawl a site

POST /web/crawl Body: { url, maxPages?, onlyMainContent?, country? } Returns: { startUrl, pages: [{ url, markdown, truncated }], pending: [ … ], stoppedBy: "maxPages"|"bytes"|"done", failed: [ … ] } Reads the start URL and follows its internal links (same domain) up to maxPages (1-20 right away, default 10). 1 query per page actually read. pending are the links seen but not visited: pass one to another call to continue. A page with truncated: true was over 150,000 characters: read it whole with /web/fetch. failed are the pages that could not be read (not charged). onlyMainContent recommended for RAG.

Large crawl (background)

With maxPages from 21 to 2,000, POST /web/crawl answers 202 { jobId, status: "running", startUrl, maxPages, queued } and the crawl keeps going on its own: it starts from the site's sitemap (what /web/map does), reads ~24 pages per minute and charges each page as it is read (starting only charges the sitemap documents). If you run out of queries it stops and keeps what it read. GET /web/crawl/:jobId?cursor=N → { status: "running"|"done"|"error", done, maxPages, queued, failed, pages: [ … ], nextCursor }. Free. Pages come in blocks: ask for cursor: nextCursor until it is null; you can read while it runs. Results are kept 7 days. No polling: webhook web.crawl.completed { jobId, startUrl, total, failed, url } (or web.crawl.failed with error). SDK: eb.webCrawl({ url, maxPages: 500 }) → { jobId } · eb.webCrawlStatus(jobId, cursor) · eb.webCrawlAll({ url, maxPages: 500 }) (waits and joins everything) · MCP: web_crawl + web_crawl_status({ jobId, cursor })

Map a site

POST /web/map Body: { url, search?, limit? } Returns: { url, urls: [ … ], total, source: "sitemap"|"links", truncated } Every URL of a site from its sitemap (found in robots.txt or at /sitemap.xml, following sitemap indexes), without reading the pages. If the site has no sitemap, it uses the internal links of the home page. search filters by text ("/blog/"); limit 1-5000, default 1000. Charges 1 query per document read (usually 1-3, max 10). Use it before reading a large site: map, pick, and read only those pages with /web/fetch. SDK: eb.webMap({ url, search }) · MCP: web_map({ url, search })

Interact with a page (remote browser)

POST /web/interact { url, actions?, screenshot? } → { url, title, text, length, truncated, done, failedAt?, error?, screenshotUrl? } Opens the page in a remote browser (gets past bot blocks), runs the actions in order and returns the visible text of where it ended up. For what /web/fetch cannot do: site search boxes, forms, "load more", tabs, filters. Actions (max 25): { type: "click", selector } · { type: "type", selector, text } · { type: "press", key } · { type: "select", selector, value } · { type: "scroll", y?: px | "bottom" } · { type: "wait", ms? | selector? } · { type: "goto", url }. If one fails it stops and returns failedAt + error with the page as it was. screenshot: true adds a capture (private URL, 24 h). 5 queries per session (≤90 s). SDK: eb.webInteract({ url, actions }) · MCP: web_interact({ url, actions })

Monitor a page

POST /web/monitor { url, everyMinutes? } → 201 { monitorId, url, everyMinutes, status: "active", … } · GET /web/monitor (list) · DELETE /web/monitor/:id Every everyMinutes (15 to 10,080, default 60) it reads the page's main content and, if it changed, fires the web.page.changed webhook { monitorId, url, changedAt, added: [ … ], removed: [ … ], totalAdded, totalRemoved } (lines, up to 40 each side). The first check only stores the baseline. It ignores counters, bare dates and "N minutes ago". Each check costs 1 query (hourly ≈ 720 a month); if you run out, the monitor pauses and web.monitor.paused arrives. Up to 50 active per account. SDK: eb.webMonitorCreate({ url, everyMinutes }) · webMonitorList() · webMonitorDelete(id) · MCP: web_monitor_create · web_monitor_list · web_monitor_delete

Service status

GET https://www.easybits.cloud/api/health/web (public, no key) → { status: "operational"|"degraded"|"down"|"unknown", checkedAt, search, fetch, extract }. Every 15 min the platform searches, reads Amazon MX and extracts from Mercado Libre the way a customer would; also shown on /status. MCP: web_crawl({ url, maxPages })

Web-specific errors

  • 402 no queries left → { code, requiredCost, available, buy }
  • 429 UPSTREAM_COOLDOWN (Retry-After: 60): the provider cooled down that query; it's temporary, retry later
  • 502 provider error · 503 service not configured

Agents & Sandboxes

Firecracker microVMs for running agents and isolated code. The tools live in the sandbox MCP group (the full catalog is at /api/tools.json).

Templates

Catalog derived from the server (templates_list gives the detail and the env each one needs). base = run code; agent = ready-made agent; service = platform boxes (started with service_start); internal = fleet workers created by the platform.

TemplateKindDescription
ubuntubaseFull Linux. Install packages, compile, run servers.
pythonbasePython runtime; each run-code is a fresh process.
nodebaseNode 24 + typescript, tsx, pnpm, git and python3; each run-code is a fresh process.
bunbaseBun runtime.
dev-boxbaseClean work box (git, curl, build-essential, Node 22); the recommended one for SSH.
code-interpreterbasePython + persistent Jupyter kernel (sandbox_run_cell): variables and charts survive between cells.
eve-nitrobaseSelf-hosted eve (Vercel) server: Node 24, pnpm, eve CLI 0.65; persistent /data, port 3000.
node-agentagentNode + Claude Agent SDK pre-baked (agent_run).
claude-codeagentClaude Agent SDK loop; per-token billing.
gooseagentgoose (AAIF), coding agent with native ACP.
ghostyclawagentAlways-on Ghosty daemon (WhatsApp, Slack, Telegram) with Docker and admin-api.
ghosty-liteagentLightweight Rust ACP agent, multi-provider; your EasyBits key can be its brain.
open-ghostyagentGhosty on open models, SSE web chat.
lang-ghostyagentGhosty on LangChain, SSE web chat.
rust-ghostyagentDeepSeek-first Ghosty (CodeWhale/Rust) with SSE web chat and WhatsApp.
ghosty-gcagentGhosty for teams (GTeams): threads, artifacts, collaborative editor.
ghosty-chatagentPersistent Ghosty chat (Express + SSE).
cagent-ghostyagentGhosty on cagent (Docker), SSE web chat.
openclawagentOpenClaw, always-on personal AI.
chat-openaiagentPersistent Express+SSE chat on OpenAI; create it with agent_create.
chat-anthropicagentPersistent Express+SSE chat on Anthropic; create it with agent_create.
ghosty-studioagentGhosty Studio: agent control plane inside a box.
desktop-ghostyagentLinux desktop with Ghosty (noVNC).
computer-ghostyagentComputer-use with XFCE desktop + public noVNC.
computer-ghosty-geminiagentComputer-use on Gemini.
livekit-svcserviceVideo call room + HD recording (Studio).
whisper-svcservicewhisper STT; part of the voice box.
kokoro-svcservicekokoro TTS; part of the voice box.
voice-svcserviceVoice (STT + TTS) for the fleet; service_start('voice').
render-svcserviceChromium for PDF/PNG/audits; service_start('render').
collab-svcserviceGTeams collaborative editor (Yjs).
hyperframes-svcserviceHyperFrames video rendering.
claude-workerinternalFleet worker (Claude). Created by the platform.
codex-workerinternalFleet worker (Codex). Created by the platform.
gemini-workerinternalFleet worker (Gemini). Created by the platform.

Create a sandbox

POST /sandboxes Body: { template, timeoutSeconds?, name?, env?, metadata?, suspendOnIdle?, hardTtlSeconds?, persistent?, size? } (or templateKey+templateHash / derivedTemplate instead of template — see Derived templates). MCP: sandbox_create({ template, timeoutSeconds?, env?, suspendOnIdle?, hardTtlSeconds? })

⚠️ Without suspendOnIdle, when timeoutSeconds runs out the box is DESTROYED — it does not go to sleep. And if you omit timeoutSeconds the default is 300 s: five minutes and the box is gone. That is the difference between "my agent is still there tomorrow" and an unexplained 404.

  • suspendOnIdle: true — on idle, SUSPEND (Firecracker snapshot) instead of destroying. Waking up takes ~0.2 s and any request to its public URL triggers it, including a WebSocket upgrade: there is no need to "poke" it first (measured 2026-09-01).
  • hardTtlSeconds — when to actually destroy it. Without this it sleeps, but the janitor sweeps anything that has been suspended for 72 h.
  • persistent: true — the box skips the age-based reaper (for always-on).

env reaches the whole guest (login shell, /exec, /bg and the template unit). What a box does on wake-up is not declared here: it is a separate call on the already-created box — see Bootstrap on resume.

For any box hosting an agent you are going to write to LATER —an ACP agent, a bot— suspendOnIdle is not optional: without it you lose the box and its URL stops serving, because the new box's sandboxId is a different one.

Idle policy on an EXISTING box

POST /sandboxes/:id/idle Body: { suspendOnIdle, idleTtlSeconds?, hardTtlSeconds? } MCP: sandbox_set_idle({ sandboxId, suspendOnIdle, idleTtlSeconds?, hardTtlSeconds? }) · SDK: sbx.setIdlePolicy({...})

For a box you created without suspendOnIdle (or one another system created for you, like an eve session): with suspendOnIdle: true the reaper SUSPENDS it when the TTL elapses instead of destroying it; idleTtlSeconds re-arms the clock from now and hardTtlSeconds is the deadline after which it is destroyed even while asleep. suspendOnIdle: false returns it to the ephemeral scheme (409 if the TTL already elapsed: extend first).

Run a command

POST /sandboxes/:id/exec Body: { command, cwd?, timeoutSeconds?, env? } MCP: sandbox_exec({ sandboxId, command })

Long-running commands (background)

POST /sandboxes/:id/bg · MCP: sandbox_exec_background({ sandboxId, command }) Status: GET /sandboxes/:id/bg/:execId · MCP: sandbox_exec_status({ sandboxId, execId }) List: GET /sandboxes/:id/bg · MCP: sandbox_exec_list({ sandboxId }) Kill: DELETE /sandboxes/:id/bg/:execId (or POST /sandboxes/:id/bg/:execId/kill) · MCP: sandbox_exec_kill({ sandboxId, execId })

exec is synchronous (60 s by default, 600 s cap): for a build, a dev server or anything that outlives the request, use background. Do not try to leave something running with nohup or & inside exec: the shell dies when it responds and takes the child with it. Write the command as exec <program> so the shell is REPLACED by your process instead of staying on as its parent.

And remember sandbox_exec_kill: without it a hung process keeps eating the box until the TTL expires. It kills the WHOLE GROUP (SIGTERM, then SIGKILL after the grace period), so any children the command forked die too. If you lost the execId, sandbox_exec_list gives it back.

Run inline code

POST /sandboxes/:id/run-code Body: { code, lang?, timeoutSeconds? } MCP: sandbox_run_code({ sandboxId, code, lang? })

Bootstrap on resume

A box restored from a snapshot comes back without booting: it does not re-run systemd, nor the entrypoint, nor .bashrc — all of that happened when the image was baked. So a box that slept for three days wakes up with the world as it was three days ago, and nothing flags it.

sandbox_set_bootstrap({ sandboxId, script, mode?, timeoutSeconds? }) POST /sandboxes/:id/bootstrap Body: { script, mode?, timeoutSeconds? }

⚠️ This is not a field of POST /sandboxes. Create does not accept it: it is a second call on the already-created box. Create first, then declare the bootstrap on its sandboxId.

The script is run by the host, on every wake-up, whatever triggered it: a request to a port's proxy, a message to the agent, the public domain. Not only when you call resume.

bash
  • Make it idempotent. It runs on EVERY wake-up: checkout -B, not -b; ln -sfn, not ln -s.
  • Available variables: EB_RESUME=1 and EB_SANDBOX_ID. The cwd is /data/work.
  • mode: "async" (default) — the box responds immediately while the script runs, so the first message does not pay for it. "blocking" waits up to timeoutSeconds before serving, for when the work HAS to be done first.
  • A failing script never leaves the box unreachable. The result is recorded (eb_boot_exit, eb_boot_err) so it is diagnosable instead of invisible.
  • Empty script = turn it off, without destroying the box.

⚠️ Never put a credential in the script. The recipe travels in the box's metadata and shows up in listings. Reference variables that already live inside the VM, or resolve the secret with $secret: from a git tool.

js
bash

This is the piece that turns skills into real memory: in a template without hot reload, an installed skill takes effect on the next wake-up, by itself.

Derived templates (template-snapshot)

What it is. You prepare ONE box (npm install, seeds, skills, config) and capture it as a derived template under a content key (key, hash). From then on every box created with that pair is born with the bootstrap done, in the time of a normal create (measured: capture ~0.5-1 s; create from the derived template ~0.6 s + ~2 s boot), no fork and no GBs copied per child: the host keeps only the disk delta (tens of MB) and mounts it under each child copy-on-write.

When. Whenever the same bootstrap repeats: one agent per conversation, one sandbox per eve session, N identical workers. If you need to branch a live box with its memory, that is snapshot + fork; a derived template is disk only, no memory, and the child cold-boots (~2 s plus whatever its runtime takes).

What goes in hash. A digest of what determines the bootstrap's content (base template, dependency manifest, seeds, skills). Never prompts or credentials: the host scrubs secrets (/etc/sandbox-env/env, /app/secrets.env, the template's env_file) and each child gets its own env at create. key and hash are path components: [A-Za-z0-9][A-Za-z0-9._:-]{0,127}.

REST:

bash

GET /template-snapshots lists · GET/DELETE /template-snapshots/:id (409 DerivedTemplateInUse while children are alive). derivedTemplate: "dt_…" in the create also works, instead of the pair.

MCP: sandbox_template_snapshot({ sandboxId, key, hash, name? }) · sandbox_list_template_snapshots() · sandbox_delete_template_snapshot({ derivedId }) · sandbox_create({ templateKey, templateHash, env }).

SDK:

ts

Limits: no credentials inside (scrub + per-child env) · a template unused for 30 days is deleted automatically · when the base template is rebaked the derived one goes stale: create answers 409 DerivedTemplateStale and you capture again with the same pair (or bump the hash if it includes the template version) · no deriving from a derived template · needs WRITE scope to capture, READ to query, DELETE to delete.

Git: make the agent's work outlive the box

A box that sleeps for three days wakes up with three-day-old code, and without a way to publish, whatever the agent wrote dies with it. These seven tools close the full loop: clone, work, publish.

text

The credential is PER CALL and does not stay in the box. token accepts the literal value or —better— a reference to your vault:

js

The token travels to the box for that operation and is wiped when it finishes. It is never written to the repo's .git/config nor shown on the command line, so a ps from inside the box does not see it. That is why there is no "clean up credentials afterwards" step: there is nothing to clean. A URL with embedded credentials (https://user:token@…) is rejected with 422 instead of being silently accepted, precisely because git WOULD persist it.

Details that matter when the caller is an agent and not a person:

  • sandbox_git_status returns data, not text: { branch, upstream, ahead, behind, clean, staged[], modified[], untracked[], conflicted[] }. It comes from porcelain=v2, so it does not change across git versions or with the system locale.
  • sandbox_git_commit with no changes returns { nothingToCommit: true } as SUCCESS. An error there invites the agent to retry, and retrying changes nothing: it is a loop.
  • sandbox_git_checkout with create: true uses -B, which is idempotent — meant to run on every start without failing with "branch already exists".
  • sandbox_git_push with force uses --force-with-lease: if someone else pushed to that branch, it fails instead of wiping out their work.
  • The commit identity goes per call (authorName / authorEmail, default EasyBits Agent), without leaving a git config written in the client's repo.

For private repos in launch_app, the same mechanism: launch_app({ repo, repoToken: "$secret:GITHUB_TOKEN" }). The token does not enter the runspec or the release tarball.

The same seven operations over REST and through the SDK:

bash
js

Codex on your ChatGPT plan (Plus/Pro) in your box

If you use Codex CLI, you can run it in your boxes on your own ChatGPT plan, with no OPENAI_API_KEY or separate tokens: it is the "remote machine" case OpenAI documents for Codex. You sign in, with your account; your session is stored encrypted in your vault and only used in YOUR boxes. (Fleet agents on the codex engine still ask for OPENAI_API_KEY until OpenAI approves use in hosted apps.)

Once (any node box):

bash

Open the link (https://auth.openai.com/codex/device), sign in with your ChatGPT account and enter the code (valid 15 min). If it says it is not allowed, turn on device code sign-in in your ChatGPT security settings (in a workspace, the admin turns it on).

Then, in any box, one step:

bash

API: POST /sandboxes/:id/codex-connect { force?, install? } → { session: "written"|"kept", codex } · POST /sandboxes/:id/codex-save → { saved }. MCP: sandbox_codex_connect · sandbox_codex_save. SDK: sb.codexConnect() · sb.codexSave(). Already have ~/.codex/auth.json on your computer? Paste it as-is as the CODEX_AUTH secret in your vault (/dash/developer/secrets or easybits secrets set CODEX_AUTH < ~/.codex/auth.json) and skip the login.

How the session is kept alive: OpenAI rotates the refresh token every time Codex renews. So connect never overwrites a session the box already has (it may be newer; --force replaces it), and when you delete a box through the API its renewed session is saved to your vault automatically first (or by hand with codex save). Use one session in one box at a time: two boxes renewing the same token clobber each other and one would have to sign in again. In the box it lives at ~/.codex/auth.json (mode 600): treat it like a password.

Persistent kernel (code-interpreter)

MCP: sandbox_run_cell({ sandboxId, code }) — state survives between cells. Matplotlib charts come back as images. MCP: sandbox_kernel_restart({ sandboxId }) — restart the kernel.

Expose a port (public URL)

POST /sandboxes/:id/expose-port Body: { port } MCP: sandbox_expose_port({ sandboxId, port }) Returns a public HTTPS URL (alive as long as the sandbox exists).

If the response carries a warning, the service is listening only on 127.0.0.1 inside the box: the URL is published but will return 502 until you bind to 0.0.0.0 (or ::). The proxy dials the guest's IP, so a loopback bind is unreachable by design — same as in Docker, Fly or Cloud Run.

Layer 7 with TLS: HTTP and WebSocket. The certificate is already at the edge, so the same URL serves https:// and wss:// — no cloudflared or raw port needed for a WebSocket. What it does not do is raw layer 4: ports 22, 23, 25, 445 and 3389 are rejected with 400. For those use the L4 forward.

Per-box network policy (egress)

POST /sandboxes/:id/network-policy · Body: "allow-all" | "deny-all" | { allow: { "api.github.com": [], "registry.npmjs.org": [] } } (no wildcards; a bare "*" = allow-all) · GET reads it back. MCP: sandbox_set_network_policy({ sandboxId, policy }) / sandbox_get_network_policy · SDK: sb.setNetworkPolicy(policy) / sb.getNetworkPolicy()

Only the domains you authorize for that box, or none. Change it live over the API, it survives suspend/resume, and if a domain is not on the list the connection simply does not leave the VM. It is in force once the call returns: apply it before the egress you want governed. Same shape as eve / Vercel Sandbox setNetworkPolicy; transform (header injection at the firewall) is not supported and is rejected with 400.

Contract details: a host that does not resolve is not an error — it shows up in unresolved and the refresh (every 60 s) retries it; a literal IP goes straight in. No wildcards (*.x → 422). Under deny-all there is no DNS; under an allow-list DNS (53) only reaches 1.1.1.1 / 8.8.8.8. Scope is Internet egress: the internal mesh and the host ports of the template role stay reachable. The response carries { policy, mode, resolved: { host: [ip] }, unresolved: [host] }. Forks and warm-pool boxes are born allow-all; the switch is atomic (no open window).

Raw ports (TCP/UDP)

POST /sandboxes/:id/expose-raw · Body: { port, protocol } ("tcp" | "udp") MCP: sandbox_expose_raw_port({ sandboxId, port, protocol }) · SDK: sb.exposeRawPort(port, protocol) Close: POST /sandboxes/:id/unexpose-raw — MCP sandbox_unexpose_raw_port · SDK sb.unexposeRawPort(port, protocol)

Returns:

json

Use endpoint as-is for the target; do not assemble it by hand. The hostPort comes from a pool (49000-49999), is different per box and is not equal to the guestPort — that is exactly what lets each box have its own 22. It is not stable either: it is released when the box is destroyed and re-assigned, so read it again instead of storing it.

It is gated by template capability: if the template does not declare that port, the response is 403 — that is a "no", not a transient failure; do not retry.

SSH into a box

POST /sandboxes/:id/ssh-enable · Body: { publicKeys: string[] } MCP: sandbox_ssh_enable({ sandboxId, publicKeys }) · SDK: sb.enableSsh(publicKeys) Close: POST /sandboxes/:id/ssh-disable — MCP sandbox_ssh_disable · SDK sb.disableSsh()

The public key comes from the CLI; do not write it by hand. easybits ssh-key creates it at ~/.ssh/easybits_ed25519 the first time and always returns the same one — and it is the one easybits ssh-proxy uses when connecting, so they cannot drift apart. Picking another one from ~/.ssh is the most common mistake: you inject a public key that does not match the private key you later connect with, and sshd answers Permission denied with everything else correct. The private key never leaves the user's machine.

A single call does both halves: it injects the keys, restarts box-sshd and exposes 22 over L4. It returns the forward plus a ready-to-paste command:

text
  • The box's sshd is fail-closed: without a key it does not start, so a box nobody injected anything into has no SSH surface, not even a closed one. That is why the key goes first.
  • Key-only access, as root (PasswordAuthentication no).
  • Several keys: one per array element. Internally they are separated by comma, not by newline.
  • The host key persists in /app/ssh/, so the fingerprint survives restarts and resume — no MITM warning on every boot.
  • ssh-disable only closes the port; the key stays injected. To truly revoke it, remove it from /app/secrets.env.
  • Only on templates that declare 22 (today: ghosty-studio).

SSH over a tunnel (recommended: no ports, goes through 443)

The command above uses a high port on the host, and a high port does not get through an office network or a corporate VPN. That reaches you as "it won't connect" from a network you cannot reproduce. The tunnel comes in through the usual 443: if the user can open a web page, they can get into their box.

sh

In ~/.ssh/config:

text

And that is it:

sh

You still need ssh-enable once, to inject the key: the box's sshd is fail-closed. Pass it the output of easybits ssh-key — it is the same key ssh-proxy will use when connecting, so they cannot drift apart. The private key never leaves your machine.

The box's name (the name from create) works as the host: ssh mi-caja.ghosty. It is neither unique nor secret — if two boxes share it the proxy fails instead of choosing, because getting into the wrong box is worse than not getting in. It does not matter that it is public: the session is authenticated with your key and the ticket, never with the name.

  • The tunnel does not authenticate. It moves opaque bytes; the SSH session is authenticated end to end between your ssh and the box's sshd. A tunnel failure gives nobody access.
  • The CLI requests a short-lived signed ticket (POST /sandboxes/:id/ssh-ticket · SDK sb.sshTicket()) and opens wss://…/_ssh. You normally do not call it yourself.
  • Expired ticket, tampered signature or someone else's box: 403/404 at the edge, without reaching the host.
  • The proxy's stdout is the SSH channel: any extra byte there corrupts the handshake, so all its diagnostics go to stderr.

Custom domains (custom domain + automatic HTTPS)

Serve a sandbox port under YOUR domain (app.cliente.com or cliente.com) instead of the sb-… URL. The TLS cert is issued on its own on first access (no egress fees, no extra config). One domain → one sandbox.

POST /sandboxes/:id/domain-add · Body: { domain, port } MCP: sandbox_domain_add({ sandboxId, domain, port }) · SDK: sb.addDomain(domain, port) Returns in dns the EXACT record to create: subdomain → CNAME to cname.sandboxes.easybits.cloud; root/apex → A to the edge IP (apex does not allow CNAME).

POST /sandboxes/:id/domain-remove · Body: { domain } — MCP: sandbox_domain_remove · SDK: sb.removeDomain(domain) POST /sandboxes/:id/domain-list — MCP: sandbox_domain_list · SDK: sb.listDomains() POST /sandboxes/:id/domain-verify · Body: { domain } — MCP: sandbox_domain_verify · SDK: sb.verifyDomain(domain) — confirms DNS + TLS cert

Flow: domain-add → create the DNS record given in dns → domain-verify. Create the record in your authoritative DNS (if your registrar delegates nameservers to another provider, edit it there).

Files

  • sandbox_files_write({ sandboxId, path, content }) — write a file
  • sandbox_files_read({ sandboxId, path }) — read a file
  • sandbox_files_list({ sandboxId, path }) — list a directory
  • sandbox_files_delete({ sandboxId, path }) — delete
  • sandbox_files_move({ sandboxId, from, to }) — move/rename
  • sandbox_files_mkdir({ sandboxId, path }) — create a directory
  • sandbox_files_edit({ sandboxId, path, oldString, newString, replaceAll? }) — surgical in-place edit (read→replace→write). Use this instead of exec+sed: it avoids shell escaping. Replaces all occurrences by default; replaceAll:false = only the first. SDK: sb.files.edit(path, old, new)

Logs & daemon runtime

  • sandbox_logs({ sandboxId, unit?, lines?, since?, grep? }) — native journald logs (no piping journalctl through exec). REST: POST /sandboxes/:id/logs with that body, or GET /sandboxes/:id/logs?unit=&lines=&grep=. unit filters one systemd service (e.g. ghosty-gc-runtime, convention <template>-runtime); omit it for the full journal. No follow/streaming. SDK: sb.logs({ unit, lines, since, grep })
  • sandbox_runtime({ sandboxId, action, unit?, buildCommand?, cwd? }) — control via systemd. status (unit state) · restart (unit required) · rebuild (runs buildCommand in cwd and restarts the unit). SDK: sb.runtime(action, { unit, buildCommand, cwd })
  • sandbox_apply_patch({ sandboxId, edits[], rebuild?, restart? }) — atomic hotfix: applies N edits → optional rebuild → optional restart, in one call. If the build fails it does NOT restart (the live daemon stays up). SDK: sb.applyPatch({ edits, rebuild, restart })

Lifecycle

  • sandbox_list() — list active sandboxes
  • sandbox_status({ sandboxId }) — state (starting/running/stopped/error/lost/suspended)
  • sandbox_extend({ sandboxId, extendSeconds? }) — extend the TTL (max per plan: Byte 1h · Mega 4h · Tera 24h)
  • sandbox_suspend({ sandboxId }) — snapshot to disk; pauses the TTL while suspended
  • sandbox_set_idle({ sandboxId, suspendOnIdle, idleTtlSeconds?, hardTtlSeconds? }) — when the TTL elapses the box is SUSPENDED instead of destroyed (long sessions with quiet periods)
  • sandbox_resume({ sandboxId }) — restore from snapshot; restores the remaining TTL (no sandbox_extend needed)
  • sandbox_destroy({ sandboxId }) — destroy and release

Skill for your agent: npx skills add https://easybits.cloud installs easybits-sandbox (the sandbox API) and easybits-cli (the same from the shell: easybits sandboxes …).

Sandbox activity and errors

GET /sandboxes/:id (SDK sb.refresh(), MCP sandbox_status) includes activity while a long operation runs on the box: "snapshotting" or "forking". With nothing in progress the field is absent. While it is present, exec, suspend, destroy, snapshot and fork answer 409 SandboxBusy right away: wait until it is gone and retry.

json

Sandbox errors come as { "error": "<code>", "message": "…" }:

StatuserrorWhat happenedWhat to do
403AgentLimitReachedYour plan already uses all its persistent boxes (ACP or brand agents), awake or asleep: Byte 1, Mega 2, Tera 5Delete an agent (existing lists them) or upgrade (upgradeUrl)
409SandboxBusyA snapshot or fork is running on the boxCheck activity with GET /sandboxes/:id and retry once it is gone (usually minutes)
409SandboxNotReadyThe box is still starting (or asleep)Wait for status=running, or resume it if suspended
409SandboxUnreachableThe host cannot reach the agent inside the box (restarting or hung)Wait and retry; if it persists, destroy it and create another
504SandboxHostTimeoutThe host did not answer in time; the operation may still be runningCheck GET /sandboxes/:id and retry in a few minutes; don't blindly repeat it
502SandboxHostErrorThe sandbox host failed (status and message carry its error)Retry; if it persists, contact support with the message

A snapshot can take several minutes (it copies disk and memory); the API waits up to 10.

Persistent agents (agent_create)

POST /agents Body: { template, env, name?, timeoutSeconds?, seedFiles?, mcpServers? } — env is required ({} if the template asks for nothing): model keys and agent config; they are written inside the VM and never come back out through the API. seedFiles: [{ name, contentBase64 }] are written to /data/workspace/<name> flattened (no subfolders: / becomes _); they are for dropping in an MCP or knowledge without cloning a repo. mcpServers (ACP templates only) travels in the session/new; see below. Responds immediately with status: building; poll GET /agents/:id until running before the first message. MCP: agent_create({ template }) — creates an agent with a public HTTP endpoint MCP: agent_list() — list agents MCP: agent_message({ agentId, content }) — send a message MCP: agent_destroy({ agentId }) — destroy an agent

🆕 ACP agents: ghosty-lite and goose

Agents that speak ACP (Agent Client Protocol) in their own microVM: ghosty-lite (Rust, lightweight, ours) and goose (AAIF / Linux Foundation). Same flow with POST /agents; what changes is the env and what running means.

1. Create — env: {} is enough: ghosty-lite starts with your own EasyBits key as its brain (provider easybits, model deepseek-v4-pro) and with the EasyBits MCP already connected. It is the same key for both things: turns are deducted from your LLM tokens (GET /llm/balance) and the agent operates on your account. If you have none stored, one is minted with the scopes of the key that made the call. For another brain, the env rules: { GHOSTY_PROVIDER: "anthropic", GHOSTY_MODEL: "claude-haiku-4-5", ANTHROPIC_API_KEY: "..." } · goose uses GOOSE_PROVIDER / GOOSE_MODEL. Providers: easybits (metered, default), anthropic, openai, custom_deepseek (+ DEEPSEEK_API_KEY, off-meter), ollama… If you only send DEEPSEEK_API_KEY, the box picks DeepSeek on its own. Your Claude subscription (Max/Pro): send CLAUDE_CODE_OAUTH_TOKEN (the one from claude setup-token) and the agent is born with GHOSTY_PROVIDER: "claude-acp" + GHOSTY_MODEL: "current" (whatever model the adapter brings); flat rate, off-meter. The metered proxy (/llm/v1/models) does NOT offer Claude: that is the only path to Claude in ghosty-lite.

2. Wait for running — for ACP it means Easybits already did initialize + session/new and stored the session (~6 s from create). Before that /message has no session.

3. POST /agents/:id/message { content } → SSE {type:"chunk",value:"…"} … {type:"done"}. Each turn reuses the session; the agent keeps context and disk (/data). Works with the owner's eb_sk or with the embedToken from a browser.

4. The machine is yours — POST /sandboxes/:sandboxId/exec to read what the agent wrote; DELETE /agents/:id destroys it. Without deleting, it sleeps on idle and wakes with the next message.

From an editor (Zed, JetBrains, VS Code), Ghosty Teams or any ACP client: put ACP_AGENT_TOKEN in the env at create time and connect over WebSocket to the agent's stable URL, which comes as agentUrl from the POST (it answers once it reaches running): wss://acp-<agentId>.sandboxes.easybits.cloud/acp?token=<ACP_AGENT_TOKEN> — the /acp is part of the URL (with the bridge npx ghosty-acp <that url>, env: {}; the keys already live in the box). Port 3000 is already exposed; no /expose needed.

5. Stable identity and revive — the URL carries the agentId, not the machine: if the host recycles the box (days without use), the URL stays the same. POST /agents/:id/revive (Bearer the owner's eb_sk or the agent's embedToken / ACP_AGENT_TOKEN) brings it back up on the same agent and returns { agentId, sandboxId, status, wsUrl }; if the box exists it does nothing. It takes as long as the boot takes (~10-60 s): wait for the response, do not retry. The previous box's disk (/data) is lost. /message does it on its own when it finds the agent in lost. A WebSocket client recognizes it by a 404 with preview host not found on the agent's URL.

6. Its system prompt (identity) — by default the agent ships with the Ghosty persona (it answers in the language it is written to). For your own: at create time, env.SYSTEM_PROMPT and env.SYSTEM_PROMPT_MODE (replace = the agent is ONLY your prompt, no house persona; append = added on top, the default). After creation: PATCH /agents/:id { systemPrompt?, systemPromptMode? } changes it live (no reboot) and GET /agents/:id/prompt returns the current one. An already-open session keeps the prompt it started with: the new one applies to the next session. POST /agents/ghosty and /agents/autonomous take the same systemPrompt / systemPromptMode in the body (there the default is replace).

7. Files, skills, MCP and restart over the API — same contract as Ghosty Studio, only on templates with a machine (ghosty-lite, goose; others answer 409 agente_sin_maquina). PUT /agents/:id/files/<ruta> (raw bytes, max 10 MB, land in /data/work; GET lists, DELETE removes; no restart). PUT /agents/:id/skills/<slug> { markdown, assets?: [{ name, contentBase64 }] } and DELETE (land in /data/agent/skills; take effect after restart; an unquoted : in the frontmatter → 400). GET|PUT /agents/:id/mcp { servers } replaces the whole list and restarts. POST /agents/:id/restart restarts the agent with a fresh session. The ghosty-agent skill (npx skills add https://www.ghosty.studio) works here with base https://www.easybits.cloud/api/v2/agents/$AGENT_ID and your EASYBITS_API_KEY.

Usage: with the easybits brain (the default) the proxy meters it and it shows in GET /llm/balance; with your own provider (BYOK) the spend is against that provider and EasyBits does not count it. The /message SSE also emits a {type:"usage", inputTokens, outputTokens, totalTokens} right before done, when the agent reports it (these are SESSION totals, not per-turn). Your own tools (mcpServers): [{ name, command, args?, env? }] (stdio) or [{ name, type:"http", url, headers? }]; $secret:NOMBRE in env/headers resolves from the owner's vault. ⚠️ With claude-acp only http MCPs get in (it announces mcpCapabilities: {http:true}, no stdio): a stdio one is rejected with 400. Bring the server up over Streamable HTTP inside the box (e.g. seeded with seedFiles) and declare it as { type:"http", url:"http://127.0.0.1:<puerto>/mcp" }. With easybits/DeepSeek (goose) stdio does work. With claude-acp tools run without asking for permission (bypass mode inside the microVM): there is no confirmation dialog to attend to.

Agent Run (one-shot)

MCP: agent_run({ prompt, model?, maxTurns? }) — asynchronous Claude agent MCP: agent_run_status({ jobId }) — check status MCP: agent_run_destroy({ jobId }) — release the sandbox

Rate limits: 10 spawns/min, 120 ops/min. Sandboxes self-destruct at the TTL (default 5 min; max per plan: Byte 1h · Mega 4h · Tera 24h).

eve (Vercel) on EasyBits

eve is Vercel's open-source agent framework: an agent is a directory (instructions, tools, channels, schedules) and every session is a durable Workflow SDK run. When an agent needs to execute code, eve asks a sandbox provider for a box. @easybits.cloud/eve-sandbox is that provider for EasyBits: every agent session runs in its own microVM, in your account, on your plan.

Versions. eve ≥ 0.64 declares sandboxes with providers (EasybitsSandbox.environment() + environment.open()); that is what this page documents, tested with eve 0.65 and @easybits.cloud/eve-sandbox 0.2. For eve ≤ 0.63 (defineSandbox({ backend, bootstrap })) pin @easybits.cloud/eve-sandbox@0.1 and @easybits.cloud/eve-world@0.1.1.

Two routes. (1) Free, without moving your server: the eve server stays where it is (Vercel, your laptop with eve dev/eve start) and only the sessions run on EasyBits; fits the Byte plan (1 concurrent box = one conversation at a time, 1-hour sessions, size s). (2) Everything on EasyBits: the eve server in an eve-nitro box + one child box per session = 2 concurrent boxes → requires Mega ($499 MXN/month, 2 boxes) or Tera ($2,490 MXN/month, 5 boxes). @easybits.cloud/eve-world (section 3) only works with the server inside EasyBits (the database URL is on the internal network), so it belongs to route 2.

1. Free route: sandboxes for eve agents, server where it already is

Requires Node ≥ 24 on the machine (eve requires it). Create the project (or use yours) and install the provider:

bash
ts

The export const environment is required: eve build looks for it to prepare the template before any session exists.

eveEasyBits
prepare() (runs in eve build; in eve dev, on first access)temporary node box + agent/sandbox/workspace/ → /workspace + skills → ~/.agents/skills + your prepare(sandbox) → derived template (template-snapshot, content key eve:<resources> + hash of what changes the image). Idempotent on the host: the first build logs easybits: plantilla dt_… lista and later ones reusada (measured: 9.7 s / 0.3 s)
open() → start()POST /sandboxes with templateKey+templateHash: the box is born with prepare done (~5 s). open()'s env goes to /etc/profile.d (login shell); networkPolicy is applied on the host. 404 DerivedTemplateNotProvisioned / 409 DerivedTemplateStale → SandboxTemplateNotProvisionedError (run eve build)
resume()reopens the box by sandboxId (wakes in ~1 s if it was asleep). If it no longer exists, it fails (eve's contract: resume reconnects, it doesn't recreate) and eve reruns the selector. recreateOnLoss: true recreates it from the template
between turnsthe box stays alive with a nap (idleTtlSeconds 600 → suspend, resume ~1 s)
stop() · delete()suspend · destroy
run / spawnbash -lc through /bg; stdout/stderr as streams, kill() signals the process group
files/files/*; relative paths anchored at /workspace, $HOME/… resolves inside the box

Options: EasybitsSandbox.environment({ prepare, apiKey, baseUrl, template: "node", timeoutSeconds, workingDirectory, runTimeoutSeconds, idleTtlSeconds, hardTtlSeconds, metadata, recreateOnLoss }) · environment.open({ env, networkPolicy }). The per-box egress policy uses the same shape eve uses on Vercel: "allow-all", "deny-all" or a per-domain allow-list ({ allow: { "api.github.com": [], "registry.npmjs.org": [] } }; "*" opens everything); it goes in open({ networkPolicy }) or live with sandbox.setNetworkPolicy(). The host resolves it to IPs per microVM with DNS refresh, persists it with the box and reapplies it on resume. Not supported: transform (injecting headers at the firewall) — it throws an explicit error. Outside eve, the same policy lives in PUT/GET /sandboxes/:id/network-policy · SDK sb.setNetworkPolicy(policy) · MCP sandbox_set_network_policy.

Model. The eve init scaffold uses Vercel's AI Gateway — model: "spacexai/grok-4.7" as a string in agent/agent.ts — which outside Vercel needs AI_GATEWAY_API_KEY. For a direct provider eve already ships helpers, nothing to install: model: anthropic() (import { anthropic } from "eve/models/anthropic", claude-sonnet-5, reads ANTHROPIC_API_KEY) or openai() from eve/models/openai (reads OPENAI_API_KEY).

Starting. eve build prepares the template, so it needs EASYBITS_API_KEY in the environment (without it: @easybits.cloud/eve-sandbox: falta apiKey); it is required before eve start. Locally, eve start answers 401 even from localhost (localDev() is ignored in production): the local session goes in with eve dev --no-ui, or with httpBasic as in section 2. On Vercel (or any Node 24) run eve start with EASYBITS_API_KEY in the environment. Preparation uses a temporary box that is destroyed once the template is captured (it doesn't take a slot afterwards); then one child box per session, which sleeps when idle and doesn't take a slot while asleep. On Byte, one conversation at a time: the next one gets SandboxLimitReached while the previous one is awake, or move up to Mega. Tested with a brand-new free account.

2. Hosted route (Mega+): the eve server inside a box

eve-nitro template: Node 24, pnpm, the eve 0.65 CLI, git/curl/tar; /data is a persistent 4 GB volume (not the working directory: exec starts in /, do an explicit cd /data); port 3000. Since it ships pnpm, eve init uses it.

Six steps: create the box, create the app inside it with eve init (or clone yours if it already exists) and install the packages, pick the model, set auth, build and start the server, expose the port. First define the base URL and headers in bash. A freshly created box is starting: exec/bg on it answer 409 SandboxNotReady, wait for status=running. pnpm 12 blocks cbor-extract's build script (eve-world brings it): without --allow-build=cbor-extract the pnpm add fails with ERR_PNPM_IGNORED_BUILDS.

bash

Writing files into the box. POST /sandboxes/:id/files/write with { path, content, encoding? } (text, or "base64"); answers { ok, bytes }. That's how agent/sandbox.ts (the one from section 1), agent/agent.ts and agent/channels/eve.ts go up:

bash

Model and world. The same helper as section 1, plus the world line (section 3); the provider key goes in eve start's env:

ts

Server auth. In production (eve start) the HTTP API requires auth: the scaffold ships agent/channels/eve.ts with vercelOidc()/localDev()/placeholderAuth() and the public URL answers 401 Authorization is required for this route. Edit it, for example with basic auth:

ts

Build, start and expose. eve build prepares the session template, so it needs EASYBITS_API_KEY in the exec's env (log easybits: plantilla dt_… lista; later builds reusada):

bash

The eve-nitro box is born with EASYBITS_DB_URL in its environment (no token): it reaches the shell, /exec and /bg without you passing it. To resume runs on a new box, create it with the same value ("env":{"EASYBITS_DB_URL":"…"} in the POST /sandboxes), which you read from metadata.eve_db_url (GET /sandboxes/:id) of the original. Without it, eve silently falls back to world-local (the box's disk) (section 3).

The public URL proxies the whole path (/eve/ and /.well-known/workflow/ reach Nitro with no configuration), but eve asks for the auth you declared: every call goes with -u eve:$EVE_PASSWORD. The project lives under /data to survive suspend/resume; if the box sleeps, relaunch eve start on wake. eve's durable state lives on disk by default; to make it outlive the box use @easybits.cloud/eve-world (section 3).

Talking to the server. Creating a session returns { sessionId }; the stream is NDJSON events (message.completed carries the answer); the same id accepts more messages:

bash

3. Durable state in EasyBits DB (@easybits.cloud/eve-world)

By default eve keeps its runs, steps, hooks and streams on the box's disk (.eve/.workflow-data). @easybits.cloud/eve-world is a Workflow SDK World on libSQL: the same state lives in EasyBits DB, so the server box can be destroyed and recreated without losing a run halfway. It's a 1:1 port of @workflow/world-postgres (lease-based delivery queue in a table) for @workflow/world@5.0.0-beta.37, the line eve 0.65 ships (for eve ≤ 0.63: eve-world@0.1.1).

bash
ts

No token to paste. When you create an eve-nitro box with POST /sandboxes, EasyBits generates its database URL (eve-<id>, created on first use; access is resolved by the host from the box's identity, the URL carries no credential) and puts it in metadata.eve_db_url. The box is born with EASYBITS_DB_URL already in its environment; to resume runs on a new box, create it with the same value. eve start env: EASYBITS_API_KEY, ANTHROPIC_API_KEY (or your provider's), EVE_PASSWORD, PORT=3000. Measured in production with eve 0.65: server destroyed, another with the same EASYBITS_DB_URL ready in 21 s, and the same session continued with its memory and its same session box; with eve 0.58, an 8-step run resumed at step 3 on a new machine 59 s after destroying the first. Outside EasyBits, WORKFLOW_LIBSQL_URL + WORKFLOW_LIBSQL_AUTH_TOKEN point at any libSQL/Turso; with no env it falls back to world-local. WORKFLOW_SERVICE_URL is only needed with several workers (default: the server itself on localhost).

Not implemented (optional in the contract): events.createBatch, queueBatch, invoke, runs.cancelMany, analytics.

MCP: sandbox_create({ template: "eve-nitro", … }) · skill: npx skills add https://www.easybits.cloud --skill easybits-eve.

Hosting — Permanent sandboxes (always-on)

An ephemeral sandbox self-destructs at the TTL. A permanent sandbox runs 24/7 and is billed flat in MXN/month. Same resource, same sandboxId — "permanent" is just a flag + billing. MCP group: hosting.

You do NOT need a paid plan to host. Hosting bills on its own, with its own subscription: someone on Free pays for their box and nothing else. The plan still gates AI, storage and fleet; it no longer gates hosting.

create_machine returns one of two things:

  • with an active plan → the machine, provisioned instantly, billed on the plan's same invoice.
  • without a plan → { checkoutUrl }. You hand that link to the customer; the machine is created on its own when the payment is confirmed (nothing runs for free in the meantime). After they pay, list_machines() shows it.

Cancelling a machine's subscription does NOT touch your plan, and vice versa: they are independent charges. On cancel, the box goes to soft delete with a 7-day grace period and a final backup is taken.

Tier catalog

GET /machines/tiers · MCP: list_machine_tiers() · SDK: eb.machines.tiers() Tiers (vCPU/RAM/NVMe → MXN/month shared):

  • nano 1/256MB/1GB → $49
  • micro 1/1GB/2GB → $99
  • estandar 2/2GB/16GB → $449
  • focus 4/8GB/32GB → $690 (reserved $1,725)
  • performance 8/16GB/64GB → $1,290 (reserved $3,225)
  • performance-4x 16/32GB/128GB → $4,980 (reserved $12,450) For a 24/7 Node app the real floor is micro: nano (256MB) runs a static binary or a side project, but does NOT survive a Node build. Disk add-on: +100GB NVMe = $99/month (stackable). Reserved CPU (guaranteed floor) only from focus up.

Create a permanent sandbox

POST /machines · Body: { tier, cpuMode?, sleep?, diskAddonsGB?, template?, name? } MCP: create_machine({ tier }) · SDK: eb.sandboxes.createPermanent({ tier }) (or eb.machines.create({ tier })) Returns the record with sandboxId, tier, monthlyMxn, status. You operate it like any other sandbox (exec, files, expose_port, domains) by its sandboxId.

Sleep when idle

sleep: true at create, or later: PATCH /machines/:sandboxId · Body: { sleep } · MCP: set_machine_sleep({ sandboxId, sleep }) · SDK: sb.setSleep(true) The whole VM (disk + in-memory process) suspends after 5 min without traffic and the next request wakes it in ~200 ms right where it left off — no cold start. Same price as shared (micro $99 MXN/month) and the only mode sold for new web apps. Requires template node and shared CPU. Not for crons, queues or websockets that must run with no visitors. After waking, outbound connections (external DB) may be dead: the app must reconnect.

Promote an ephemeral to permanent

POST /machines · Body: { fromSandboxId, tier } MCP: make_permanent({ sandboxId, tier }) · SDK: sb.makePermanent(tier) Keeps the SAME sandboxId, disarms the reaper and starts billing.

List / release

MCP: list_machines() · SDK: eb.machines.list() — your permanent sandboxes with tier + monthlyMxn. DELETE /machines/:sandboxId · MCP: release_machine({ sandboxId }) · SDK: sb.release() — removes the charge (prorated) and destroys the VM. Destructive, idempotent.

The plan is the access gate; each sandbox bills separately. If your plan is cancelled, your sandboxes are suspended.

launch_app — an app in production in ONE call

The equivalent of fly launch. It provisions the machine, puts in the code, builds, starts, exposes a public HTTPS URL, publishes the recovery release and optionally attaches the customer's domain. Returns { url, releaseId, domain.dns }.

Use this instead of chaining create + deploy + expose + domain by hand. Done by hand, the step that gets skipped is the release — and a machine without a release cannot be rebuilt if it dies.

Source: exactly ONE of the three.

  • repo — git clone. The reproducible path.
  • archiveUrl — URL of a .tar.gz/.zip of the app; for when the customer uploads it from their computer and has no repo yet. Accepts zip and tarball, and flattens the nested folder a zip usually comes with.
  • sandboxId — the agent already wrote the app inside that box; do the rest.

MCP: launch_app({ repo | archiveUrl | sandboxId, tier?, port?, dataPaths?, domain? }) REST: POST /machines/launch · SDK: eb.machines.launch({ … })

Defaults: tier: "micro" (nano is 256MB — it does NOT survive a Node build), template: "node" (Node 24 + npm/pnpm; ubuntu does NOT ship Node), appDir: "/app", buildCommand: "(npm ci || npm install) && npm run build", startCommand: "npm start", port: 3000.

App variables (non-secret) go in env: { PORT: "4000", API_URL: "…" }. They are exported before the build and before start; vault secrets win by name. Keys must be shell identifiers (A-Z_0-9).

What we learned with a real app (React Router v7 + Express 5):

  • npm start with node --env-file=.env server.js dies in the box: there is no .env. Start with startCommand: "node server.js" and pass config through env + secrets.
  • Express 5 rejects app.all("*", …) (path-to-regexp 8). Use app.use(handler).
  • Public GitHub repos clone without a token. A private one: a clean URL + the token separately in repoToken (accepts $secret:NAME). Do not put the token in the URL (https://x-access-token:TOKEN@…): it is rejected with 422 RepoUrlHasCredentials, because git would store it in .git/config inside the box.

⚠️ PHP: no URL ending in .php is served. The EasyBits edge answers 404 to every .php/.asp/.jsp path and to dotfiles (/.env, /.git, wp-admin…) before they reach the box: they are what scanners probe most, and every probe used to wake sleeping boxes. PHP does work behind a front controller with clean URLs (Laravel, Symfony, Slim: /login, not /login.php); classic WordPress or pages like /contact.php do not. launch_app flags it in warnings when it finds composer.json or .php files.

Redeploy the same machine from the repo (the direct flow, no workflow): secrets first, then launch_app({ sandboxId, repo, … }). It publishes a new release (v2, v3…) and restarts the app in ~18 s. sandboxId is the TARGET; repo the source.

Measured times with a real React Router v7 app (204 MB of node_modules, 49.5 MB release), on tier micro:

steptime
provision the box3.7 s
npm ci + build inside the box (first deploy)6.9 s
publish the prebuilt release11.3 s
redeploy to a clean box12.0 s

A heavier app takes longer, mostly downloading the release. With prebuilt: true the deploy runs no build: it downloads, extracts and starts.

⚠️ Build INSIDE the box, not on your machine. A node_modules with native modules (sharp, better-sqlite3) compiled on macOS blows up on Linux. The first deploy pays for the npm ci; from then on you publish prebuilt and every deploy takes seconds.

If the build fails, the machine launch_app created is released on its own: it does not leave you paying for a broken box. A box you passed via sandboxId is NEVER released; if you sent it new code (repo or archiveUrl) and the build fails, it goes back on its own to the release it was serving — the site stays up and the error says so (rolledBackTo).

It also works without a paid plan: if the account has no plan, launch_app (and create_machine) return { checkoutUrl } instead of failing. You hand that link to the customer; when they pay, the machine is created on its own and shows up in list_machines(). Then you call launch_app again with its sandboxId to deploy on top.

Releases — making the box rebuildable

Fly/Vercel treat the disk as disposable because the deploy rebuilds it from an image. Here the app is written INSIDE the box, so without a release a dead box takes the app with it, not just the data. A release is a versioned tarball of the code in durable storage + a runspec that says how to build and start it.

Release = CODE. Backup = DATA. A box recreated from a release starts empty.

  1. set_machine_runspec({ sandboxId, appDir, buildCommand?, startCommand?, unit?, port?, dataPaths? }) — required before the first deploy. dataPaths is what the daily backup copies: without it the machine has NOTHING backed up. Do not put secrets in env (it is stored and travels inside every tarball).
  2. deploy_machine({ sandboxId, message? }) → publishes a release. SDK: eb.machines.deploy(id)
  3. list_machine_releases({ sandboxId }) · rollback_machine({ sandboxId, releaseId }) — goes back to a previous version on the SAME box. No rebuild: a release published by launch_app carries its build (prebuilt), so rollback and redeploy are download + extract + start. In exchange the tarball is heavier (it carries node_modules).
  4. get_machine_logs({ sandboxId, lines?, grep? }) — THE APP's log (last N lines): the unit's journal if the runspec has one, or the file the startCommand writes to. First place to look when the deploy said it started and the site does not answer. SDK: eb.machines.logs(id, { lines }).
  5. redeploy_machine({ releaseId, tier?, replaceSandboxId? }) — builds a NEW box. It serves two purposes: recovering a dead machine and changing tier (there is no hot resize — it is recreated). replaceSandboxId releases the old one once the new one is confirmed running; without it you pay for both.

REST: PUT /machines/:id/runspec · POST|GET /machines/:id/releases · POST /machines/:id/rollback · POST /machine-releases/:releaseId/redeploy (separate collection: works even if the original machine no longer exists) · GET /machines/:id/logs?lines=200&grep=.

Backups — included, 7 days

Every night the runspec's dataPaths are copied to durable storage off the host, 7-day retention, at no extra cost (same deal Fly gives on volumes). The operating system is not backed up: it is rebuilt from the template.

  • list_backups({ sandboxId }) · create_backup({ sandboxId }) — the latter, before anything risky.
  • restore_machine_from_backup({ backupId, targetSandboxId?, confirm: true }) — overwrites data, which is why it requires confirm. Restoring onto the source box takes an automatic backup first. With targetSandboxId you restore to a new box (e.g. one just made with redeploy_machine).

Two limits stated up front: the RPO is 24 hours, and the backup is taken from the live filesystem, so a DB writing during the copy may end up inconsistent — each backup reports its level in consistency. If the app has a DB, keep it outside the box (EasyBits Databases, Atlas) or stop the service before create_backup.

Full recovery of a machine = redeploy_machine (code) + restore_machine_from_backup (data).

App secret variables

Your app needs its DATABASE_URL, its STRIPE_SECRET_KEY. Do not put them in runspec.env: that is stored in the database and travels inside every release tarball. The API rejects them by name.

PUT /machines/:sandboxId/secrets · Body: { "DATABASE_URL": "...", "JWT_SECRET": "..." }

The values are stored encrypted in your vault and the runspec keeps only the LIST of names. They are materialized inside the machine —in a file only root can read— right before building and before starting. They do not enter the release: a box rebuilt from a tarball still does not carry them inside, but it knows which ones to ask for.

  • GET /machines/:id/secrets → { secretNames, inVault }. Names, never values: a stored secret is never read back through the API.
  • DELETE /machines/:id/secrets?name=DATABASE_URL → stops injecting it (the value stays in the vault).

Each machine has its own. A secret you set on a machine applies to that machine only: two apps in the same account can each have their own JWT_SECRET or FACTURAPI_KEY (several businesses, one account). If the machine has none of its own under that name, it gets the one from your general vault. A redeploy_machine carries them to the new box.

The general vault (your account's, the same as secret_set and /dash/developer/secrets) has REST too: GET /secrets → { items:[{id, name, createdAt, lastUsedAt}] } (names only), POST /secrets { name, value } → 201 (same name = replace; [A-Z_][A-Z0-9_]*), DELETE /secrets/:name → { deleted }. SDK: eb.listSecrets(), eb.setSecret(name, value), eb.deleteSecret(name). CLI: easybits secrets ls|set|rm.

They take effect on the next deploy, not on the fly. Rotating a secret means changing it here and deploying again. If the runspec declares one that is not in the vault, the deploy fails naming which one — better than watching the app die on connect.

Scheduled reports from your app's data

Typical case: every morning you want an operations report (sales, orders by status, appointments) built from your app's database. The rule that drives the design: a deployed app's database sits behind the host (DATABASE_URL=http://172.20.0.1:8090/v1/db/<app>/, the host signs each request) and is only reachable from inside its machine. So the report is computed in the app; whatever is outside only decides when.

1. A report route in your app, protected with a secret of that machine:

bash
ts

The numbers are computed by code (SQL), never by a model. A model adding up loose rows will sooner or later invent a figure.

2. The output. JSON or CSV in the response; a PDF with structured_doc (REST/MCP, no browser); store it in the app's bucket or in EasyBits Files and, if needed, email it from the app.

3. What triggers it:

  • A timer inside the app (node-cron or setInterval): only works if the machine does not sleep (sleep off). With sleep on, the timer stops with the box.
  • From outside, calling the URL with the secret: works even if the machine sleeps (the request wakes it). For example a GitHub Actions schedule: workflow with curl -fsS -X POST -H "Authorization: Bearer $REPORT_SECRET" https://<your-domain>/api/reports/operations.

4. With an agent ("almost an automation"). A Ghosty agent with a scheduled turn calls that route and writes the summary for whoever reads it (what went up, what got stuck, what to check):

bash

The agent gets the numbers already computed: it interprets and writes, it does not count.

Deploy from GitHub on every push

One GitHub webhook and you are done: every push to the machine's branch redeploys it. No workflow, no Actions.

1. Deploy once from the repo. A clean URL and, if it is private, the token separately (a fine-grained PAT with Contents: Read on that repo):

bash

The token is stored in your vault for you (GIT_TOKEN_<id>); if it is already there, pass "$secret:NAME". The machine remembers which repo and branch it came from (runspec.source; of the token, only the secret's NAME).

2. Turn on push-deploy and paste what it returns in GitHub → Settings → Webhooks → Add webhook:

bash

Payload URL = webhook.url, Content type = application/json, Secret = webhook.secret (shown only this time; calling again rotates it), Just the push event. GitHub sends a ping that should come out green.

MCP: push_deploy({ sandboxId }) · SDK: eb.machines.enablePushDeploy(id) · turn off: DELETE /machines/:id/push-deploy · status: GET.

What happens on every push:

  • Only the machine's branch deploys; other branches are ignored.
  • It answers 202 right away and builds in the background, inside the box (Linux: native modules compile correctly). Each push leaves a release with the commit message, so history and rollback work as always.
  • If the build fails, the machine goes back on its own to the version it was serving and you get an email with the tail of the error. A broken push does not take the site down.
  • Two pushes in a row run two deploys, no more: the ones in between collapse into the latest.

If your build does not fit in the box (bundlers that want 4 GB), build on the GitHub runner and send the result: npx @easybits.cloud/cli init writes that workflow (build on the runner → POST /files → POST /machines/launch with { sandboxId, archiveUrl, prebuilt: true }). The runner is Linux x64 like the microVM; building on a Mac does break native modules like sharp or better-sqlite3.

Dashboard (UI)

They are also managed from /dash/hosting: each site with its status and its address, and when you open one, four tabs — Domains (with the DNS record to create and whether it resolves yet), Versions (with one-click rollback), Variables and Log (the last lines of the log). From there you can also pause and cancel.

Your crons (any account)

Every account schedules its own jobs: call a URL or send a message to one of your fleet agents. EasyBits dispatches them every minute; every run is recorded (time, duration, status, summary and error). They repeat (cron, at most every 5 min) or run once (at/in). Limit per plan: Byte 3, Mega 20, Tera 100 (one-off crons that already ran do not count). IANA time zone (default America/Mexico_City). After 20 failures in a row the cron turns itself off.

  • GET /api/v2/crons — { items, nextCursor, hasMore, total } (?include=agents adds agents:[{id,name}]).
  • POST /api/v2/crons — { name, cron | at | in, until?, times?, timezone?, enabled?, action, dryRun? }. Schedule: cron (5 fields, repeats; until = last date, times = how many more runs) or at (once: ISO with offset, ISO without offset —read in timezone— or "23:00" = today) or in ("10m", "2h", "1h30m", "1d"). An at in the past → 400 (no guessing "tomorrow"). After running, a one-off cron becomes enabled:false, done:true and is kept with its history. dryRun:true validates everything without creating and answers { dryRun, schedule, timezone, nextRuns:[{at, local}], in } (the next 3). The response has kind (once|recurring), nextRunAt (ISO), nextRunLocal (in the cron's zone) and in ("12h 54m"). action is { type: "http", url, method?, body?, secretName?, secretHeader? } (public https; secretName is a secret from your vault sent as a header, Authorization: Bearer … by default; redirects are not followed) or { type: "agent_turn", fleetAgentId, prompt, groupId? } (conversation cron:<id> by default) or { type: "exec", command, files?, secrets?, template?, timeoutSeconds? }: a throwaway box (python default · node · bun · ubuntu) that mounts files ([{ path, content, encoding? }], relative path = /root/job, ~/ = /root; up to 20 and 512 KB), runs command in /root/job (success = exit 0, timeoutSeconds 300 by default, max 600) and is destroyed. secrets: [{ name, file? }]: without file it arrives as env var name; with file it is written there (chmod 600) and <name>_FILE points to the path. The value comes from secretValues (top-level { NAME: value }, stored encrypted scoped to that cron: no other cron, agent or machine can read it, it is deleted with the cron and never comes back) or from your vault. The dashboard copilot rejects secretValues (its proposals are stored as-is).
  • GET|PATCH|DELETE /api/v2/crons/:id — PATCH { enabled: false } pauses it; PATCH { at } makes it one-off (and reschedules one that already ran), PATCH { cron } repeating; until/times set to null remove them.
  • POST /api/v2/crons/:id/run — run now (202, in the background, trigger: "manual"). GET /api/v2/crons/:id/runs/next?wait=50&after=ISO — long-poll: waits up to 50 s for the next run to FINISH (the one in progress, or one starting after after) and returns it with logs: { run, after, running, nextRunAt, nextRunLocal }; on run: null call again with the same after; 409 when there is nothing to wait for. notify (in POST/PATCH): { on?: "failure"|"always", push?: true | { secret }, email?: true } — push to the Ghosty app with the personal token in the GHOSTY_PUSH_TOKEN secret (400 if missing), email only to the owner account; null removes it. Every run also emits the cron.run.succeeded / cron.run.failed webhooks. GET /api/v2/crons/:id/runs — history; each run has exitCode (for exec) and with ?logs=1 also logs (tail of stdout+stderr, 64 KB).
bash

An exec can run with the config of one of your machines: action.machine (id or name; CLI --machine) passes its runspec.env and secrets (DATABASE_URL, EASYBITS_DB_URL…) on every run, without copying them. GET /api/v2/crons?include=agents,machines lists your machines.

MCP: list_my_crons (with id: limit up to 50 runs and logs:true; crons are named by id), create_cron (with at/in/until/times/dryRun, exec and secretValues), update_cron (also notify), delete_cron, run_my_cron, wait_cron_run (core group). Dashboard: "Mis crons" section at /dash/developer/crons. For the result: with a server, the cron.run.* webhooks; from a terminal, notify.inbox (below) or crons wait.

In the panel (/dash/developer/crons) there is Bittor, a fleet agent per account that uses these same tools: it builds the cron from what you ask in plain words, explains why one failed and reads your databases (SELECT only) to answer about your data. It never changes anything on its own: every create, change, delete or run comes up as a card that you approve with your session. To create them from a coding agent, use the easybits-crons skill.

Event inboxes (your own webhook that reaches your terminal)

An inbox is an EasyBits webhook URL: anyone writes to it with POST (your cron, Stripe, GitHub, a form) and what arrives reaches you live if you are listening or waits for you if not. Made for an agent in a terminal, which has no public URL. 7-day retention by default (1-30), up to 1,000 events per inbox, body up to 256 KB; limit per plan Byte 3, Mega 20, Tera 100.

  • POST /hooks/<token> (public; the token IS the write authorization, anyone with the URL can write) — any body; answers 202 { ok, seq }. Headers are kept except credentials (authorization, cookie, x-api-key…), so a signature like stripe-signature comes through.
  • GET|POST /api/v2/inboxes — list ({ items:[{ id, name, url, unread, lastSeq, lastEventAt, retentionDays, createdAt }] }) / create { name, retentionDays? }. GET|PATCH|DELETE /api/v2/inboxes/:id (id or name); POST /api/v2/inboxes/:id/rotate = new URL (the old one stops accepting).
  • GET /api/v2/inboxes/:id/events?since=&limit=&ack= — without since, the unread ones; ack (default 1) marks them read up to what was delivered. { items:[{ seq, source (http|cron), method, contentType, headers, body, json?, receivedAt }], cursor, unread }.
  • GET /api/v2/inboxes/:id/events/stream?since= — live SSE: event: inbox per event (id = seq; reconnect with Last-Event-ID). Listening does not mark as read.
  • Crons: notify: { inbox: "<id or name>", on: "always" } (the id is stored) drops each result as an event { event: "cron.run.succeeded"|"cron.run.failed", cronId, name, runId, ok, exitCode, summary, error, … }.
bash

MCP: inbox_create, inbox_list, inbox_read, inbox_update, inbox_rotate, inbox_delete (core group). SDK: listInboxes, getInbox, createInbox, updateInbox, rotateInbox, deleteInbox, readInbox, listenInbox; waitCronRun(id, { timeoutMs, after }) returns the latest after so the next call never misses the run. At the start of a session, an agent reads its inboxes with inbox_read to see what happened while it was away.

Platform scheduled jobs (platform admins only)

Daily machine backups, their rotation, the hosting monitor (every 5 min) and the weekly purges are EasyBits scheduled jobs. Their schedule lives in code and every run is recorded (time, duration, who triggered it, summary and error). They are viewed and operated at /dash/admin/crons or through the API. They require the ADMIN scope and being a platform admin; any other account gets 403.

  • GET /api/v2/admin/crons — { items }: per job key, name, path, cron, schedule (in words, Spanish), status (al-dia up to date · tarde late · fallo failed · pausado paused · nuevo no runs yet), paused, running, nextRunAt, lastRun and recent (last 20, newest last). Late = the scheduled time passed and it is still within the grace period (10% of the period, at least 5 min); failed = the grace period passed or the last run ended with an error.
  • GET /api/v2/admin/crons/:key/runs — last 50 runs with the full error.
  • POST /api/v2/admin/crons/:key/run — runs it in the background (trigger: "manual") and answers 202 right away. 409 if it is already running or paused.
  • POST /api/v2/admin/crons/:key/pause — { "paused": true | false }. While paused, the route answers { "skipped": "paused" } without running.

Panel assistant

/dash/admin/crons has an assistant (a fleet agent, "Asistente de admin", one per superuser) that answers only with data from these MCP tools of the admin-crons group:

  • list_crons — the same status as GET /api/v2/admin/crons.
  • get_cron_runs({ job, limit? }) — latest runs with the full error; this is how it explains why a job failed.
  • run_cron({ job }) and pause_cron({ job, paused }) — do not execute: they create a pending action and return { needs_confirmation: true, actionId, summary } (a success, not an error, so the model does not retry). The summary is built from the real job name, never from model text.

All four require a superuser; the two that propose need the WRITE scope, not ADMIN: the assistant's key lives in its microVM and with ADMIN it could skip the confirmation by calling the API directly.

Confirmation: the chat speaks AG-UI (POST /api/v2/admin/copilot/chat with a RunAgentInput → SSE of AG-UI events; contract in ghostykit's docs/protocol.md). Each proposal ends the run with RUN_FINISHED outcome: { type: "interrupt", interrupts: [{ id, message, expiresAt, metadata: { tool } }] }. It is answered with another POST on the same thread: resume: [{ interruptId, status: "resolved", payload: { approved: true | false } }], using only the session of the superuser who proposed it (any Authorization → 403). It runs exactly once (repeating the resume does not run it again) and expires after 15 min (RUN_ERROR interrupt_expired). A POST with no message and no resume reconnects: MESSAGES_SNAPSHOT plus the live turn or the open cards.

Databases (SQLite-as-a-Service)

Create isolated SQLite databases for your agents and apps. Powered by sqld (libsql-server).

Skill for your agent: npx skills add https://easybits.cloud installs easybits-cli: easybits db create|query|tables|ls|rm with --json.

List databases

GET /databases Returns: { items: Database[] } SDK: eb.listDatabases() MCP: db_list

Create database

POST /databases Body: { name: string, description?: string } Name must be alphanumeric/dashes/underscores, max 64 chars. Limit depends on plan (Byte: 3, Mega: 10, Tera: 20). Returns: Database object. SDK: eb.createDatabase({ name, description? }) MCP: db_create({ name, description? })

Get database

GET /databases/:dbId SDK: eb.getDatabase(dbId) MCP: db_get({ dbId })

Rename database

PATCH /databases/:dbId Body: { name?: string, description?: string|null } (name: letters, digits, -, _, max 64; 409 if you already have one with that name). Only metadata: the id and the data do not change, so db_* calls by dbId keep working. Prompts that name the old name do not update themselves: easybits db rename rewrites them.

Delete database

DELETE /databases/:dbId Permanently deletes the database and all its data. SDK: eb.deleteDatabase(dbId) MCP: db_delete({ dbId })

Query database

POST /databases/:dbId/query Body: { sql: string, args?: any[] } Returns: { cols: string[], rows: any[][], affected_row_count: number, last_insert_rowid: string|null } SDK: eb.db(name).query(sql, args?) MCP: db_query({ dbId, sql, args? })

Batch execute

POST /databases/:dbId/query Body: { statements: [{ sql, args? }] } (max 20) Returns: { results: Result[] } MCP: db_exec({ dbId, statements })

Bulk import

POST /databases/:dbId/query Body: { table: string, columns: string[], rows: any[][], onConflict?: "ignore" | "replace" } Up to 10,000 rows per request. Column/table names must be alphanumeric + underscores. Returns: { imported: number, total: number } MCP: db_import({ dbId, table, columns, rows, onConflict? })

Errors

Query errors come as { "error": "…", "code": "<code>" }:

StatuscodeWhat happenedWhat to do
400SQL_ERRORThe statement failed (syntax, missing table, constraint); error carries SQLite's messageFix the SQL
409DATABASE_STORAGE_MISSINGThe database exists in your account but its storage is missing on the server; its data is not availableDelete it and create a new one, or contact support
502DATABASE_BACKEND_ERRORThe database backend failedRetry in a moment
404—The database does not exist or is not yoursCheck the id with GET /databases

Database object

json

Webhook events

  • database.created — new database created
  • database.deleted — database deleted

Limits

  • Max databases per plan: Byte 3, Mega 10, Tera 20
  • Name: alphanumeric, dashes, underscores, max 64 chars
  • Batch: max 20 statements per request

Files

List files

GET /files Query: limit? (default 50), cursor?, assetId?, includeWebsiteFiles? (default false) Returns: { items: File[], nextCursor?: string } Website/document/presentation files (sites/<websiteId>/…) are excluded unless includeWebsiteFiles=true; list them with GET /websites/:websiteId/files. SDK: eb.listFiles({ limit?, cursor?, assetId? })

Upload file

POST /files Body: { fileName, contentType, size, access?: "public"|"private", region?: "LATAM"|"US"|"EU", assetId? } Returns: { file: File, putUrl: string } Then PUT putUrl with raw bytes to upload. SDK: eb.uploadFile({ fileName, contentType, size, access?, region? })

Get file

GET /files/:fileId Returns: File object with readUrl (presigned, expires 1h). SDK: eb.getFile(fileId)

Update file

PATCH /files/:fileId Body: { name?, access?: "public"|"private", metadata?, status?: "DONE" } Changing access copies the object between public/private buckets. SDK: eb.updateFile(fileId, { name?, access?, metadata? })

Delete file

DELETE /files/:fileId Soft-deletes (status → DELETED). Recoverable for 7 days. Returns 409 WEBSITE_FILE if the file backs a published website (same for POST /files/bulk-delete, all-or-nothing): manage those through the website endpoints. SDK: eb.deleteFile(fileId)

Restore file

POST /files/:fileId/restore Restores a soft-deleted file back to DONE. SDK: eb.restoreFile(fileId)

List deleted files

GET /files?status=DELETED Returns deleted files with daysUntilPurge. SDK: eb.listDeletedFiles({ limit?, cursor? })

Duplicate file

POST /files/:fileId/duplicate Body: { name? } Creates a new storage copy + DB record. SDK: eb.duplicateFile(fileId, name?)

Search files (AI-powered)

GET /files/search?q=query Requires an AI key configured. Returns up to 20 matches. SDK: eb.searchFiles(query)

File object

json

Error Codes

StatusMeaning
400Bad request — invalid params or body
401Unauthorized — missing or invalid API key
403Forbidden — insufficient scope or not your resource
404Not found — resource doesn't exist
409Conflict — duplicate or state conflict
413File too large (max 5 GB)
429Rate limited — too many requests
500Server error

Error response format:

json

Tool Groups

Un solo endpoint MCP sirve todas las tools; el grupo decide cuántas se cargan en la sesión. Cargar de más gasta contexto y empeora la elección de tool, así que empieza por el grupo de tu caso y amplía si hace falta.

GrupoToolsPara qué
design95Canva-like: documentos como diseño universal (cualquier formato — letter, social, slide 16:9), brand kits e imágenes. Ideal para Claude.ai / Claude Design.
web15Internet para tu agente: buscar en Google, leer cualquier página aunque bloquee bots, extraer registros con esquema (Maps, Mercado Libre, Amazon, Instagram…) y rastrear sitios. Se mide en consultas (packs en /dash/packs).
core114Lo esencial: archivos, DBs, documentos, forms, websites, brand. Para agentes generalistas.
ghosty29Curado y mínimo para agentes Ghosty (DeepSeek): set DB completo + documentos + archivos + imagen (gpt-image-2). Sin brand/forms/websites/media para no floodear la elección de tool.
ghostyapp10Para el agente del TELÉFONO (app Ghosty): imágenes, buscar y leer la web, fotos e iconos de stock, captura de una página y link para compartir. Deliberadamente sin DB, sites, forms ni brand: nadie hace eso desde un móvil, y cada tool de más empeora la elección.
docs—Solo tools de documentos (create/update/deploy + quotations + structured_doc).
sites—Solo tools de websites (crear, deployar archivos, inject HTML, dominios propios).
brand—Solo tools de brand kits (CRUD + extract desde URL).
magnet20Toolset orquestado para crear lead magnets (PDF + landing + form).
video23Video con IA (Runway Gen-4.5) + personajes recurrentes, y proyectos de video doc-style (escenas animadas HTML → MP4 con narración kokoro). Ghosty arma escenas, música y voz, y renderiza.
sandbox81MicroVMs Firecracker para correr agentes y código aislado. Ciclo de vida (spawn/extend/suspend/resume/destroy), snapshot + fork (clonar una caja viva en N hijos copy-on-write para explorar variantes en paralelo), exec (sync + background), run-code, kernel Jupyter persistente (run_cell con estado + charts), archivos (write/read/list/delete/move/mkdir), expose_port (URL pública), sandbox_admin (passthrough admin :8787) + agent_run (Claude managed con billing por token). Ideal para harness de agentes (Claude Code, Codex) y ejecución segura.
fleet3Agentes de la flota (WhatsApp, WABA, web, Teams): listar, leer su configuración y aplicar cualquier acción de /capabilities por MCP — prompt, motor, modelo, secrets, conectores (add-mcp + set-cap-level), skills, buckets por canal, reciclar caja. Lo que hace el dashboard de /dash/flota, hecho por un agente.
hosting35Hosting de apps sobre microVMs Firecracker, always-on, tiers fijos en MXN/mes (nano…performance). launch_app pone una app en producción en UNA llamada (caja + código + build + URL pública + release + dominio con TLS) — el equivalente a fly launch. Además: releases con rollback y redeploy (que es también el cambio de tier), backups diarios de datos incluidos con 7 días de retención, dominios propios, logs, y delegación de acceso para co-administrar. El plan da acceso; cada máquina factura flat al mes.
payments2Links de pago con MercadoPago (BYO): conecta tu cuenta y genera cobros. El dinero va directo a tu MP; EasyBits no retiene fondos. Ideal para que un agente cierre la venta.
email7Email transaccional + audiencia + newsletters one-shot. send_email, contactos con tags, y broadcasts con unsubscribe automático. La capa de nurturing del funnel.
public-safe3Subset mínimo para agentes públicos (B2C / WhatsApp customer-facing): upload_file, create_share_link, db_select (read-only SQL con anti-stacking, anti-CROSS-JOIN, anti-sqlite_master). Sin listar workspace, sin eliminar, sin webhooks/websites/secrets, sin internet abierto. Diseñado para NanoClaw FORMMY_PUBLIC_TEMPLATE.
claude85La URL que se publica en el directorio de conectores de Claude (/api/mcp/claude): archivos, documentos, sitios, formularios, brand kits, bases de datos e investigación web. Sin generar imagen, video ni voz con IA, sin cobros ni hosting, sin secretos, webhooks, cajas ni flota. Strict: run_tool tampoco sale de aquí.
scripting3Superficie mínima para agentes que escriben SCRIPTS en vez de invocar tools. En vez de cargar las ~140 schemas (impuesto fijo de contexto que DeepSeek no admite), el agente usa discover_tools (buscar) + run_tool (ejecutar) y/o la REST API v2 desde su Bash. Solo deja en tools/list el IO de archivos (multipart incómodo por curl); todo lo demás se alcanza sin reconectar. Patrón Anthropic 'Code execution with MCP' / Cloudflare 'Code Mode'.
all—Todas las tools disponibles (~50+). Ideal para agentes avanzados.

stdio (Claude Code, Claude Desktop)

bash

HTTP (Cursor, VS Code, Windsurf, Claude.ai)

text

¿No sabes cuál? Conéctate sin --tools y usa discover_tools({ query }): busca en el catálogo completo y run_tool ejecuta cualquiera sin cargarla en la sesión.

The remaining sections are in Spanish for now: /docs. Every section is also available as markdown at /en/docs/<section>.md and answers Accept: text/markdown.