API documentation
Sandboxes, web, files, databases, documents and hosting for AI agents. Base URL: https://www.easybits.cloud/api/v2
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.
npx skills add https://easybits.cloud export EASYBITS_API_KEY="eb_sk_live_…"
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.
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:
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):
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:
MCP — Claude Code (one command):
MCP — Cursor / VS Code / Windsurf (Streamable HTTP):
SDK:
By default only the core group is loaded. Enable more with --tools, separating
groups with commas:
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
- In Claude: Settings → Connectors → Add custom connector and paste the URL.
- Claude sends you to EasyBits: sign in (or create a free account).
- The consent screen shows which app is asking for access and which domain you will return to. Click Allow.
- 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/mcpto 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_targetotherwise).- 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
| Endpoint | Spec |
|---|---|
/.well-known/oauth-protected-resource/api/mcp/claude | RFC 9728 |
/.well-known/oauth-authorization-server | RFC 8414 |
/oauth/register | RFC 7591 |
/oauth/authorize, /oauth/token | OAuth 2.1 + PKCE |
/oauth/revoke | RFC 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…):
Then paste this prompt, changing the end to whatever you need:
The agent will hand you a link to sign in the first time; it does the rest.
Install
Requires Node 22 or later.
Log in
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
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
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)
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
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
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)
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:
A sandbox's domains point a domain at a port of a sandbox or machine:
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.
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
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)
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.
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.
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
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
Configure an agent with a machine (ghosty-lite, goose; same contract as Ghosty Studio's ghosty CLI). Every write takes --dry-run:
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
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
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
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
MCP, SSH and docs
SSH over 443 — add to ~/.ssh/config and connect with ssh <name>.ghosty:
--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.
| Code | Meaning |
|---|---|
0 | ok |
1 | API error (4xx/5xx), or domains verify not ready yet |
2 | usage error: unknown or missing command, subcommand or argument |
3 | not 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
--jsonand 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-browserskips 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--jsonto readstdout,stderrandexitCodetogether. - Deleting with
--jsonrequires--yes; without it the command exits2. - Before touching an agent's config, preview with
--dry-run; after, verify witheasybits 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(includeseasybits-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? })
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 })
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_postsdatasetId: 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 }.mercadolibreresponds 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 }
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
402no queries left →{ code, requiredCost, available, buy }429UPSTREAM_COOLDOWN(Retry-After: 60): the provider cooled down that query; it's temporary, retry later502provider error ·503service 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.
| Template | Kind | Description |
|---|---|---|
ubuntu | base | Full Linux. Install packages, compile, run servers. |
python | base | Python runtime; each run-code is a fresh process. |
node | base | Node 24 + typescript, tsx, pnpm, git and python3; each run-code is a fresh process. |
bun | base | Bun runtime. |
dev-box | base | Clean work box (git, curl, build-essential, Node 22); the recommended one for SSH. |
code-interpreter | base | Python + persistent Jupyter kernel (sandbox_run_cell): variables and charts survive between cells. |
eve-nitro | base | Self-hosted eve (Vercel) server: Node 24, pnpm, eve CLI 0.65; persistent /data, port 3000. |
node-agent | agent | Node + Claude Agent SDK pre-baked (agent_run). |
claude-code | agent | Claude Agent SDK loop; per-token billing. |
goose | agent | goose (AAIF), coding agent with native ACP. |
ghostyclaw | agent | Always-on Ghosty daemon (WhatsApp, Slack, Telegram) with Docker and admin-api. |
ghosty-lite | agent | Lightweight Rust ACP agent, multi-provider; your EasyBits key can be its brain. |
open-ghosty | agent | Ghosty on open models, SSE web chat. |
lang-ghosty | agent | Ghosty on LangChain, SSE web chat. |
rust-ghosty | agent | DeepSeek-first Ghosty (CodeWhale/Rust) with SSE web chat and WhatsApp. |
ghosty-gc | agent | Ghosty for teams (GTeams): threads, artifacts, collaborative editor. |
ghosty-chat | agent | Persistent Ghosty chat (Express + SSE). |
cagent-ghosty | agent | Ghosty on cagent (Docker), SSE web chat. |
openclaw | agent | OpenClaw, always-on personal AI. |
chat-openai | agent | Persistent Express+SSE chat on OpenAI; create it with agent_create. |
chat-anthropic | agent | Persistent Express+SSE chat on Anthropic; create it with agent_create. |
ghosty-studio | agent | Ghosty Studio: agent control plane inside a box. |
desktop-ghosty | agent | Linux desktop with Ghosty (noVNC). |
computer-ghosty | agent | Computer-use with XFCE desktop + public noVNC. |
computer-ghosty-gemini | agent | Computer-use on Gemini. |
livekit-svc | service | Video call room + HD recording (Studio). |
whisper-svc | service | whisper STT; part of the voice box. |
kokoro-svc | service | kokoro TTS; part of the voice box. |
voice-svc | service | Voice (STT + TTS) for the fleet; service_start('voice'). |
render-svc | service | Chromium for PDF/PNG/audits; service_start('render'). |
collab-svc | service | GTeams collaborative editor (Yjs). |
hyperframes-svc | service | HyperFrames video rendering. |
claude-worker | internal | Fleet worker (Claude). Created by the platform. |
codex-worker | internal | Fleet worker (Codex). Created by the platform. |
gemini-worker | internal | Fleet 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.
- Make it idempotent. It runs on EVERY wake-up:
checkout -B, not-b;ln -sfn, notln -s. - Available variables:
EB_RESUME=1andEB_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 totimeoutSecondsbefore 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.
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:
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:
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.
The credential is PER CALL and does not stay in the box. token accepts the literal value or
—better— a reference to your vault:
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_statusreturns data, not text:{ branch, upstream, ahead, behind, clean, staged[], modified[], untracked[], conflicted[] }. It comes fromporcelain=v2, so it does not change across git versions or with the system locale.sandbox_git_commitwith 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_checkoutwithcreate: trueuses-B, which is idempotent — meant to run on every start without failing with "branch already exists".sandbox_git_pushwithforceuses--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, defaultEasyBits Agent), without leaving agit configwritten 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:
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):
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:
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:
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:
- 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-disableonly 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.
In ~/.ssh/config:
And that is it:
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
sshand the box's sshd. A tunnel failure gives nobody access. - The CLI requests a short-lived signed ticket (
POST /sandboxes/:id/ssh-ticket· SDKsb.sshTicket()) and openswss://…/_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
stdoutis the SSH channel: any extra byte there corrupts the handshake, so all its diagnostics go tostderr.
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 filesandbox_files_read({ sandboxId, path })— read a filesandbox_files_list({ sandboxId, path })— list a directorysandbox_files_delete({ sandboxId, path })— deletesandbox_files_move({ sandboxId, from, to })— move/renamesandbox_files_mkdir({ sandboxId, path })— create a directorysandbox_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/logswith that body, orGET /sandboxes/:id/logs?unit=&lines=&grep=.unitfilters 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 sandboxessandbox_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 suspendedsandbox_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.cloudinstallseasybits-sandbox(the sandbox API) andeasybits-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.
Sandbox errors come as { "error": "<code>", "message": "…" }:
| Status | error | What happened | What to do |
|---|---|---|---|
| 403 | AgentLimitReached | Your plan already uses all its persistent boxes (ACP or brand agents), awake or asleep: Byte 1, Mega 2, Tera 5 | Delete an agent (existing lists them) or upgrade (upgradeUrl) |
| 409 | SandboxBusy | A snapshot or fork is running on the box | Check activity with GET /sandboxes/:id and retry once it is gone (usually minutes) |
| 409 | SandboxNotReady | The box is still starting (or asleep) | Wait for status=running, or resume it if suspended |
| 409 | SandboxUnreachable | The host cannot reach the agent inside the box (restarting or hung) | Wait and retry; if it persists, destroy it and create another |
| 504 | SandboxHostTimeout | The host did not answer in time; the operation may still be running | Check GET /sandboxes/:id and retry in a few minutes; don't blindly repeat it |
| 502 | SandboxHostError | The 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:
The export const environment is required: eve build looks for it to prepare the template before any session exists.
| eve | EasyBits |
|---|---|
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 turns | the box stays alive with a nap (idleTtlSeconds 600 → suspend, resume ~1 s) |
stop() · delete() | suspend · destroy |
run / spawn | bash -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.
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:
Model and world. The same helper as section 1, plus the world line (section 3); the provider key goes in eve start's env:
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:
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):
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:
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).
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/.zipof 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 startwithnode --env-file=.env server.jsdies in the box: there is no.env. Start withstartCommand: "node server.js"and pass config throughenv+ secrets.- Express 5 rejects
app.all("*", …)(path-to-regexp 8). Useapp.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 422RepoUrlHasCredentials, because git would store it in.git/configinside 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:
| step | time |
|---|---|
| provision the box | 3.7 s |
npm ci + build inside the box (first deploy) | 6.9 s |
publish the prebuilt release | 11.3 s |
| redeploy to a clean box | 12.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.
set_machine_runspec({ sandboxId, appDir, buildCommand?, startCommand?, unit?, port?, dataPaths? })— required before the first deploy.dataPathsis what the daily backup copies: without it the machine has NOTHING backed up. Do not put secrets inenv(it is stored and travels inside every tarball).deploy_machine({ sandboxId, message? })→ publishes a release. SDK:eb.machines.deploy(id)list_machine_releases({ sandboxId })·rollback_machine({ sandboxId, releaseId })— goes back to a previous version on the SAME box. No rebuild: a release published bylaunch_appcarries its build (prebuilt), so rollback and redeploy are download + extract + start. In exchange the tarball is heavier (it carriesnode_modules).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 thestartCommandwrites to. First place to look when the deploy said it started and the site does not answer. SDK:eb.machines.logs(id, { lines }).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).replaceSandboxIdreleases 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 requiresconfirm. Restoring onto the source box takes an automatic backup first. WithtargetSandboxIdyou restore to a new box (e.g. one just made withredeploy_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:
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 (sleepoff). 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 withcurl -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):
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):
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:
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=agentsaddsagents:[{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) orat(once: ISO with offset, ISO without offset —read intimezone— or"23:00"= today) orin("10m","2h","1h30m","1d"). Anatin the past → 400 (no guessing "tomorrow"). After running, a one-off cron becomesenabled:false, done:trueand is kept with its history.dryRun:truevalidates everything without creating and answers{ dryRun, schedule, timezone, nextRuns:[{at, local}], in }(the next 3). The response haskind(once|recurring),nextRunAt(ISO),nextRunLocal(in the cron's zone) andin("12h 54m").actionis{ type: "http", url, method?, body?, secretName?, secretHeader? }(public https;secretNameis a secret from your vault sent as a header,Authorization: Bearer …by default; redirects are not followed) or{ type: "agent_turn", fleetAgentId, prompt, groupId? }(conversationcron:<id>by default) or{ type: "exec", command, files?, secrets?, template?, timeoutSeconds? }: a throwaway box (pythondefault ·node·bun·ubuntu) that mountsfiles([{ path, content, encoding? }], relative path =/root/job,~/=/root; up to 20 and 512 KB), runscommandin/root/job(success = exit 0,timeoutSeconds300 by default, max 600) and is destroyed.secrets: [{ name, file? }]: withoutfileit arrives as env varname; withfileit is written there (chmod 600) and<name>_FILEpoints to the path. The value comes fromsecretValues(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 rejectssecretValues(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/timesset tonullremove 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 afterafter) and returns it withlogs:{ run, after, running, nextRunAt, nextRunLocal }; onrun: nullcall again with the sameafter; 409 when there is nothing to wait for.notify(inPOST/PATCH):{ on?: "failure"|"always", push?: true | { secret }, email?: true }— push to the Ghosty app with the personal token in theGHOSTY_PUSH_TOKENsecret (400 if missing), email only to the owner account;nullremoves it. Every run also emits thecron.run.succeeded/cron.run.failedwebhooks.GET /api/v2/crons/:id/runs— history; each run hasexitCode(forexec) and with?logs=1alsologs(tail of stdout+stderr, 64 KB).
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; answers202 { ok, seq }. Headers are kept except credentials (authorization,cookie,x-api-key…), so a signature likestripe-signaturecomes 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=— withoutsince, 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: inboxper event (id=seq; reconnect withLast-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, … }.
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 jobkey,name,path,cron,schedule(in words, Spanish),status(al-diaup to date ·tardelate ·fallofailed ·pausadopaused ·nuevono runs yet),paused,running,nextRunAt,lastRunandrecent(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 asGET /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 })andpause_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.cloudinstallseasybits-cli:easybits db create|query|tables|ls|rmwith--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>" }:
| Status | code | What happened | What to do |
|---|---|---|---|
| 400 | SQL_ERROR | The statement failed (syntax, missing table, constraint); error carries SQLite's message | Fix the SQL |
| 409 | DATABASE_STORAGE_MISSING | The database exists in your account but its storage is missing on the server; its data is not available | Delete it and create a new one, or contact support |
| 502 | DATABASE_BACKEND_ERROR | The database backend failed | Retry in a moment |
| 404 | — | The database does not exist or is not yours | Check the id with GET /databases |
Database object
Webhook events
database.created— new database createddatabase.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
Error Codes
| Status | Meaning |
|---|---|
| 400 | Bad request — invalid params or body |
| 401 | Unauthorized — missing or invalid API key |
| 403 | Forbidden — insufficient scope or not your resource |
| 404 | Not found — resource doesn't exist |
| 409 | Conflict — duplicate or state conflict |
| 413 | File too large (max 5 GB) |
| 429 | Rate limited — too many requests |
| 500 | Server error |
Error response format:
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.
| Grupo | Tools | Para qué |
|---|---|---|
design | 95 | Canva-like: documentos como diseño universal (cualquier formato — letter, social, slide 16:9), brand kits e imágenes. Ideal para Claude.ai / Claude Design. |
web | 15 | Internet 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). |
core | 114 | Lo esencial: archivos, DBs, documentos, forms, websites, brand. Para agentes generalistas. |
ghosty | 29 | Curado 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. |
ghostyapp | 10 | Para 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). |
magnet | 20 | Toolset orquestado para crear lead magnets (PDF + landing + form). |
video | 23 | Video 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. |
sandbox | 81 | MicroVMs 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. |
fleet | 3 | Agentes 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. |
hosting | 35 | Hosting 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. |
payments | 2 | Links 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. |
email | 7 | Email transaccional + audiencia + newsletters one-shot. send_email, contactos con tags, y broadcasts con unsubscribe automático. La capa de nurturing del funnel. |
public-safe | 3 | Subset 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. |
claude | 85 | La 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í. |
scripting | 3 | Superficie 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)
HTTP (Cursor, VS Code, Windsurf, Claude.ai)
¿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.