> Documentation index: https://docs.beetl.io/llms.txt

# The sandbox

> Where package code runs, what it can reach, the limits it has to fit in, and how failures look.

Source: https://docs.beetl.io/connect/sandbox/
Last updated: 2026-10-03

Every time Beetl runs code from a Connect package, it starts a fresh, isolated sandbox for that one
job: a build, a connection test, a step of an OAuth sign-in, or a sync run. The sandbox is discarded
when the job ends, along with anything your code held in memory or wrote to disk. Only what Beetl
stores for you carries over: committed records, the sync's checkpoint, and the connection's
credentials and authorization.

The sandbox protects Beetl, other tenants and other connections from package code. Within its own
connection, a package can call any endpoint its credentials allow, so install only packages you
trust. See [The agent trust boundary](https://docs.beetl.io/concepts/agent-trust-boundary).

## Network access

Package code can reach hosts on the public internet over HTTPS on port 443. Everything else is
blocked, including:

* private network addresses and `localhost`
* other ports, and plain HTTP
* Beetl's internal services
* inbound connections, so a package can't receive callbacks or webhooks

A package can't read a system that is only reachable on a private network or over a VPN. If that
system can send HTTP requests, have it push data to a [webhook connection](https://docs.beetl.io/sources/webhook).

Make every HTTP request through `ctx.fetch`, `ctx.json` or `ctx.paginate`. Other HTTP clients,
including the global `fetch`, can't open connections from the sandbox. The helpers reach only the
connection's origin and its OAuth endpoints. See [Authentication](https://docs.beetl.io/connect/oauth-and-rest#requests).

A build can reach only the public npm registry. Dependencies are installed from your lockfile with
install scripts turned off, so a dependency that needs a postinstall step or downloads a binary while
installing won't work. Sync runs can't install anything.

## Limits

Each job has its own time and memory budget:

| Job             | Time limit |
| --------------- | ---------- |
| Sync run        | 15 minutes |
| Build           | 15 minutes |
| Connection test | 2 minutes  |
| Each OAuth step | 2 minutes  |

| Resource                  | Limit per job                                                         |
| ------------------------- | --------------------------------------------------------------------- |
| Memory                    | 1 GiB                                                                 |
| Temporary files in `/tmp` | 512 MiB, shared with your unpacked package and counted against memory |

A sync run also has to fit these limits:

| Per sync run                        | Limit     |
| ----------------------------------- | --------- |
| Staged output                       | 1 GiB     |
| Batches                             | 10,000    |
| Records plus deletions in one batch | 10,000    |
| Size of one batch                   | 8 MiB     |
| Distinct primary keys (Merge)       | 5,000,000 |
| One provider response               | 16 MiB    |
| One log message                     | 4 KiB     |
| Log text                            | 1 MiB     |

A run that goes over any of these fails and writes nothing. Yield whole pages as batches, not single
records, or you run out of batches long before output.

If a full read might not fit in one run, bound the work. In an Append or Merge sync, stop after a set
number of pages, yield a checkpoint that marks the spot, and return; the next run continues from
there. A Replace run has to read everything, because it replaces the whole dataset.

## The runtime

Package code runs on Node.js 24 as ES modules, with the dependencies installed at build time. Node's
built-in modules, such as `node:crypto`, `node:zlib` and `node:timers/promises`, are available. Deno
APIs are not.

The filesystem is read-only apart from `/tmp`, which is new for every job. There are no
environment variables for package configuration. Settings reach your code through `ctx.config`, and
the SDK adds credentials to requests as your auth definition describes. Your code has no API for
reading credentials or tokens.

## Logging

Write logs with `ctx.log.debug`, `ctx.log.info`, `ctx.log.warn` and `ctx.log.error`. Messages from a
sync run appear on the connection's **Logs** tab, where they are kept for 30 days.

* Only the message text is kept. The optional `fields` argument isn't shown, so put the values you
  need into the message.
* After a message larger than 4 KiB, or once a run has logged 1 MiB, Beetl stops recording messages
  for the rest of that run.
* Output from `console.log`, `console.error` or anything else written to stdout or stderr is not
  captured.
* Don't log credentials, tokens or whole records.

## Failures and retries

Inside a run, the SDK retries individual requests as described under
[Retries and rate limits](https://docs.beetl.io/connect/oauth-and-rest#retries-and-rate-limits).

If your code throws or the run hits a limit, including the time limit, the run fails and writes
nothing. Checkpoints yielded during it are dropped, and the next run starts from the last committed
checkpoint. Beetl doesn't rerun a run that failed in package code; the next scheduled run or **Run
now** tries again. Failures on Beetl's side are retried automatically, each in a fresh sandbox.

The failure reason on the **Runs** tab doesn't include your error's message. Log the detail before
you throw:

```ts
try {
  yield { records: await ctx.json("/v1/items", Items) };
} catch (error) {
  await ctx.log.error(`Listing items failed: ${error instanceof Error ? error.message : "unknown error"}`);
  throw error;
}
```

A job is stopped at its time limit, and cleanup code may not run. Pass `ctx.signal` to any waiting
you do yourself, as the [example](https://docs.beetl.io/connect/oauth-and-rest#example) does.

A connection test isn't retried, so keep `verify` to one small authenticated request that throws
when access is wrong. OAuth steps aren't retried either; the user chooses **Connect with OAuth**
again. A rejected refresh token puts the connection into **Reauthorization required** and stops its
syncs until someone reconnects.

## Related

- [Authentication](https://docs.beetl.io/connect/oauth-and-rest): Auth modes, token refresh, pagination and retries.

- [Connection types, connections and syncs](https://docs.beetl.io/concepts/connections-and-integrations): Runs, checkpoints and the Logs tab.

- [The agent trust boundary](https://docs.beetl.io/concepts/agent-trust-boundary): What package code can and can't change.
