Building
Sandbox model
What your plugin can and cannot reach, and the memory, time and network limits it runs under.
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) |
| 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.* |
| 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.getSecretfetches 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.agentsandhost.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.mediaalso 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 | 128 MB |
Compaction summarize |
120 s | 128 MB |
Decision noul / choose / score |
10 s | 128 MB |
speak / voices / transcribe |
15 s / 10 s / 20 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 for media |
SSH hooks using host.ssh.command |
raised to cover the SSH connection | 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.* 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 |
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. |
| Lifecycle event | Retried with 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.
Live connections are dialled by a separate service that additionally refuses private, loopback, link-local and cloud-metadata addresses and re-checks the address it actually connects to.
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.