The sandbox
Where package code runs, what it can reach, the limits it has to fit in, and how failures look.
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.
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.
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.
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
fieldsargument 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.erroror 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.
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:
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 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
Last updated on