# Sandbox model

Users install plugins without reading their code. That only works if the code can't do more than it says. Here is
exactly what plugin code runs inside, what it can reach, and the limits it runs under.

## One fresh isolate per call

Every invocation — one tool call, one middleware hook, one lifecycle event, one channel message — runs in a
**new V8 isolate** that is created for that call and thrown away after it. Nothing survives between calls: no
module-level variables, no caches, no timers. State that must persist goes in your own storage (via `host.fetch`),
in the agent's workspace (`host.workspace`), in a live connection's `state`, or in the settings the user set.

Your code sits behind a gRPC boundary in a separate service. It never runs inside the platform's own process.

## What the isolate doesn't have

A bare JavaScript engine (ES2022): no `fetch`, no `XMLHttpRequest`, no `require` or `import()` at run time, no
`process`, no filesystem, no `Buffer`, no `atob`/`btoa`, no `TextEncoder`/`TextDecoder`. The only way out is
`host`:

| Need | Use |
| --- | --- |
| HTTP | `host.fetch` — only to hosts you declared ([egress](plugin-manifest.md#egress)) |
| Hashes, HMACs, AES-CBC decrypt, Ed25519, JWT (RS256) checks, base64, hex | `host.crypto` (synchronous) |
| Settings and secrets | `host.settings.get` / `getSecret` |
| The chat, todos, timers, other agents | `host.session.*`, `host.agents.*` |
| Schedules, customer sign-ins, your licence | `host.cronJobs.*`, `host.oauth.*`, `host.license()` |
| Files | `host.workspace.*` |
| A model | `host.session.llm`, `host.llm`, `host.decide` |
| Logs | `host.log` |

For binary data, `host.fetch` can send and receive base64 (`bodyEncoding` / `responseEncoding`).

## Secrets and tokens stay outside

- `host.settings.getSecret` fetches a secret into your code only when you call it, and only a secret your
  plugin declared and the user bound.
- The credential behind `host.session`, `host.agents` and `host.models` — a short-lived token scoped to one account
  (and one session when there is one) — is held by the runtime outside the isolate. Your code calls a function;
  the runtime makes the request.
- Upload URLs used by `host.media` also stay outside; your code only sees opaque tokens.

## Limits

### Per invocation

| Invocation | Wall clock | Memory |
| --- | --- | --- |
| Tool, lifecycle event, web search / fetch, chat `complete` | 30 s | 128 MB |
| Middleware hook | 5 s (1 s for `companionSpeech`) | 128 MB |
| Compaction `summarize` | 120 s | 128 MB |
| Decision `noul` / `choose` / `score` | 10 s | 128 MB |
| `speak` / `voices` / `transcribe` / `startLiveTranscription` | 15 s / 10 s / 20 s / 10 s | 128 MB |
| Channel `receive` | 10 s (15 s per attachment fetch, 25 s for the whole request) | 128 MB |
| Any invocation holding `host:media:transcode` | raised to at least 60 s | raised to 512 MB |
| SSH hooks holding `host:ssh:command` | raised to at least 120 s | 128 MB |

The wall clock is a real timer: it also counts time your code spends waiting on `host.fetch` or other host calls.
When it runs out, the isolate is destroyed mid-flight.

### Per call to `host`

| Surface | Limit |
| --- | --- |
| `host.fetch` | 60 requests per minute per installation (rolling), 10 s timeout, 5 MB response |
| `host.session.sendError` | 5 per invocation |
| other `host.session.*`, `host.agents.*`, `host.models.*`, `host.cronJobs.*` operations | no per-invocation count (bounded by your wall clock) |
| `host.crypto.*` | 200 operations and 8 MB of input per invocation; 1 MB of data per call; 10 `rsaSha256Sign` |
| `host.session.llm.complete` | 5 per invocation |
| `host.llm.complete` | 5 per invocation (counted separately) |
| `host.decide.*` | no per-invocation cap (bounded by your wall clock) |
| `host.media.*` | 10 per invocation |
| `host.ssh.command` | 10 per invocation, 60 s default / 90 s max each |
| `host.connection.send` | 20 frames per call, 20 calls per invocation |

### Per plan (per account)

| | Free | Solo | Entrepreneur |
| --- | --- | --- | --- |
| Plugin wakes per hour (each channel `receive`, each live-connection open / receive / timer) | 60 | 600 | 3,000 |
| Live connections held at once | 1 | 5 | 20 |

## What happens when your code fails

Faults are one of: **timeout**, **memory limit**, **bad request** (your handler returned the wrong shape), or
**crash** (it threw; the message is cut to 500 characters).

The platform is built so a broken plugin degrades one feature, never the agent:

| Capability | On failure |
| --- | --- |
| Middleware | **Skipped** — the value passes through unchanged and the chain continues ("fail-open"). A handler that returns the wrong shape is skipped the same way. A hook the plugin lists in `manifest.failClosed` blocks the operation instead. |
| Lifecycle event | Logged and skipped; the handler is not run again. Only an infrastructure failure delivering the event is retried (exponential backoff, 30 s base, up to 8 attempts). The install or chat that triggered it is never blocked. |
| Tool | The error becomes the tool's result; the model sees it and the turn continues. |
| Web search / fetch provider | The model sees a "provider failed" tool error. It is **not** silently swapped for the built-in engine. |
| Compaction | The built-in summarizer runs instead and a notice is posted in the chat. |
| Model provider | Treated as the vendor being unreachable; use `tools.fail(…)` for a precise error. |
| Channel `send` | Retried for a few minutes; throw an error containing `[rejected]` to stop retrying. |
| Channel `receive` | A timeout is retried; any other error is final and logged. |

## Network boundary — precisely

Outbound HTTP from `host.fetch` is **deny-by-default**: only hosts you declared in `manifest.egress`, plus any the
installer added through a `hosts` setting, are reachable — and the installer sees that list before installing.
The check is by host name (and port) against that list, and it is repeated on every redirect hop.

`host.fetch` also refuses private, loopback, link-local and cloud-metadata addresses, checked at the moment it
connects. Live connections are dialled by a separate service with the same refusals.

## Being a good citizen

- Keep middleware fast. It runs in the middle of an agent's turn with a 5 s budget; a slow hook delays every reply
  on the account and is skipped anyway when it runs out.
- Make lifecycle handlers idempotent — they can run more than once.
- Log sparingly with `host.log` (`debug`, `info`, `warn`, `error`). Logs show on the installed plugin's page and
  are kept for 24 hours.
