npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pi-roundtable-sandbox

v0.9.8

Published

Sealed guest channels and an allow-listed credential broker for pi-roundtable

Readme

pi-roundtable-sandbox

Sealed guest channels for pi-roundtable 0.7. Source lives in packages/sandbox in the pi-roundtable repository and releases in lockstep with the core. Each addressed message runs a small tool-using agent in a new Docker container with no network and no real credentials. A host-side Unix-socket broker is its only way out. Channel memory and recent conversation text persist in the channel's dedicated workspace between containers.

Requirements

  • Bun 1.4.2 or later and pi-roundtable >=0.8.0 <0.9.0.
  • A native Linux host with a local Docker daemon and a non-root service account permitted to run Docker.
  • The worker UID/GID match that account; root workers are refused.
  • A trusted OpenAI-compatible, non-streaming Chat Completions endpoint supporting function tools and max_tokens.
  • A host-side API credential for that endpoint. A subscription-specific login is not necessarily compatible with Chat Completions.

macOS supports offline development, tests, and building the image. Actual sandbox execution requires Linux: Docker Desktop does not share host Unix sockets into its Linux VM. Remote Docker daemons are unsupported because bind paths and Unix sockets must exist on the same host.

Setup

Install the package alongside pi-roundtable after it is published:

bun add pi-roundtable-sandbox

Build the image from the installed package, or from this repository during development:

docker build -f node_modules/pi-roundtable-sandbox/worker/Dockerfile -t pi-roundtable-sandbox:local node_modules/pi-roundtable-sandbox

Add the plugin to roundtable.config.ts:

import { join } from "node:path";
import { sandbox } from "pi-roundtable-sandbox";

const home = process.env.HOME;
if (!home) throw new Error("HOME is required");

// Merge with the rest of the configuration generated by pi-roundtable init.
export const guestPlugins = [sandbox({
  image: "pi-roundtable-sandbox:local",
  runRoot: "/tmp/roundtable-sandbox-run",
  workspaceRoot: join(home, ".local/share/roundtable-sandbox/workspaces"),
  stateFile: join(home, ".local/share/roundtable-sandbox/state/channels.json"),
  provider: "openai",
  model: "operator-selected-model",
  modelUrl: "https://api.openai.com/v1/chat/completions",
})];

Choose a model available to your endpoint and sign the host into its provider. By default, context.apiKey(provider) is read on every request, allowing login refresh without copying credentials into the worker. Alternatively supply apiKey: () => process.env.MODEL_API_KEY when the endpoint uses a separate key. apiKey receives { channel, speaker } (SandboxCredentialScope) before every model call, so a host can pick a different key per channel, speaker or principal, for example apiKey: ({ channel }) => keys.get(channel) or apiKey: ({ speaker }) => keys.get(speaker.principalId); a zero-argument function keeps working. speaker.principalId is the principal the host's identity service resolved the author to; it never reaches the worker. When the hook has no key it should return undefined (the call fails closed) rather than fall back to the host's own login. Keep secrets in the service's protected environment, not configuration source or the worker image. All directories must be dedicated to this package and owned by the service account. Existing roots and the state directory must have no group/other permissions; insecure permissions are refused at startup. The uid and gid options may make deployment settings explicit but must match the host process. Keep runRoot short enough for a Unix socket path (at most 100 characters including the per-turn suffix). Keep stateFile outside both roots; it is host-only routing state. Keep the roots outside Docker's daemon data directory; Docker rejects private propagation for bind sources inside that directory. Back up that state and the workspaces together, without following workspace symlinks. Only one host process may own these directories.

Restart the bot, then run /roundtable sandbox on in a guild channel as the owner. The command root follows your discord.rootCommand setting. The claim outranks the core agent-server claim (priority 200 versus 100), including when the owner speaks there. It answers non-bot, non-integration guild messages that mention the bot or reply to it, from an author the host's access rules serve. Give guests a tier there, for example access.members.roles naming their guild role; the sandbox ignores an author the rules serve no one by, as it ignores a message whose author could not be resolved. Other messages are dropped, never routed to a host agent. /roundtable sandbox status shows the mode; /roundtable sandbox off retains memory and restores normal routing after queued work completes. These commands are guarded by the published owner-command helper and serialized through the shared channel queue. Do not disable sandbox routing in a channel that should remain a guest-only boundary. initialChannels seeds the first state file only; subsequent starts use its saved state. The SANDBOX service exposes channels and runtime for other trusted plugins. Threads have separate channel keys and do not inherit a parent channel's sandbox mode; enable each thread explicitly through the trusted channel service if needed.

Allowed host tools

No host tool is enabled by default. Supply only capabilities you intend every guest in every configured sandbox channel to use:

import type { HostTool } from "pi-roundtable-sandbox";

const tools: HostTool[] = [{
  name: "clock",
  description: "Read the current UTC time",
  parameters: { type: "object", properties: {}, additionalProperties: false },
  run: (_input, context) => {
    context.signal.throwIfAborted();
    return new Date().toISOString();
  },
}];

Pass tools to sandbox(). Host tool and MCP server/tool names must match [a-z][a-z0-9_]{0,47}. The worker exposes them as host_clock, while the broker accepts only POST /tools/clock. The host binds context.channel and context.speaker to the current admitted turn; context.speaker.principalId is the principal the author was admitted as. The callback signal combines the request's deadline/closure and the turn's cancellation, and aborts when either scope ends. Body fields cannot change that identity, even if a guest replaces the worker or forges a broker call. Callbacks must validate every input field, enforce their own authorization and rate limits, obey cancellation, and avoid returning secrets. Parameter schemas are model guidance, not a host-side validator. Do not expose shell execution, arbitrary file paths, arbitrary URLs, owner memory, or a generic tool dispatcher. All configured tools are available to all sandbox channels; use a context-aware callback when policies differ.

Optional MCP tools

Supply mcp: [{ name, url, apiKey, tools }] to allow specific tools of specific servers. Each tool declares name, description and a JSON-schema parameters object. The worker exposes names such as mcp_0_search and sends calls through POST /mcp/<server>. Only single tools/call requests naming a declared tool are accepted. Unknown servers, tools, RPC batches, resources, prompts, discovery and arbitrary RPC methods are refused. The endpoint is operator-configured and never sent to the container. apiKey is optional for unauthenticated servers and otherwise read on each call; the broker inserts a bearer credential. This minimal adapter supports stateless Streamable HTTP endpoints returning JSON directly. It does not perform initialization/session negotiation, accept SSE, or provide a raw general-purpose MCP proxy. Choose narrow server tools, and apply server-side authorization as well.

Threat model

Treat guest prompts, model outputs and everything in a container as untrusted, including a worker compromised into arbitrary code execution. The operator, Docker daemon, Linux kernel, built image, host callbacks and upstream model/MCP services are trusted. Docker is a process-isolation boundary, not a VM or a guarantee against kernel vulnerabilities. Use a patched host and a dedicated machine for higher-risk workloads.

A container has:

  • --network none, a read-only root filesystem, all capabilities dropped, and no-new-privileges.
  • A non-root UID/GID, 512 MiB RAM with no additional swap, one CPU, 64 PIDs, and a bounded temporary filesystem.
  • Exactly two bind mounts: its channel workspace (writable) and its current-turn broker directory (read-only). The workspace remains executable; the threat model already assumes arbitrary container code execution and does not rely on noexec.
  • No Docker socket, host home, owner workspace, auth file, host environment, host tool registry or other channel's files.
  • Only the public tool descriptions, current message and speaker, system prompt, time zone, and a dummy API key in worker code.

The worker talks to the broker over a read-only Unix-socket mount. The host reads its final reply from Docker stdout, never from a guest-writable socket or file path. A new container and broker are created per turn and never reused across channels. On cancellation, the run CLI is killed with SIGKILL and the exact named container is force-removed; each removal command has a five-second deadline. Cleanup failures end the turn with an error and a generic host log requiring operator inspection, rather than hanging a channel queue. A short second removal covers the local create/attach race. An unresponsive daemon or delayed create request may still leave a stopped orphan; treat daemon failures and host crashes as an operator cleanup condition. Containers use --log-driver none to avoid unbounded daemon log files and --pull never to run only the image the operator already built. Channel keys are hashed for workspace names and are not interpreted as paths. The channel queue prevents overlapping turns; other channels run independently.

The broker accepts only POSTs to /model, explicitly listed /tools/<name>, and explicitly listed /mcp/<server>. Queries, extra path segments, unknown routes, hop headers, cookies, proxy headers, forwarded headers, unknown headers and non-dummy caller credentials are refused. Request headers are rebuilt rather than forwarded. The model route fixes the complete upstream URL, model, non-streaming mode and output-token limit; the guest cannot select another endpoint or increase that limit. It accepts only system/user/assistant/tool roles with string content and function tool calls. Media URLs, account file references, audio payloads and native provider tools are refused. Schema keywords $ref, $dynamicRef, $recursiveRef, $id, $schema and $vocabulary accept only local fragments, not remote URLs. Checks visit actual schema positions, not property names or enum/const data. Upstream redirects are refused and no upstream response headers or cookies reach the guest. Only bounded JSON responses are accepted; obvious raw, URL-encoded, base64, base64url and hex credential reflections are refused after JSON decoding. This is defense in depth for trusted upstreams, not a detector for every possible encoding or secret fragment. Errors are generic and never include upstream URLs, headers or exception text. No broker payloads or credentials are logged.

The real model/MCP credential exists only on the host and is swapped in immediately before an upstream call. This protects the credential value, not the ability to spend it through the allowed model endpoint during a turn. A malicious guest can call allowed routes directly and exercise every granted capability. Default bounds are 48 broker calls per turn, 256 KiB request bodies, 1 MiB upstream/tool responses, one in-flight broker call, a 60-second upstream deadline, a 120-second turn deadline, and 4,096 output tokens per model request. Each broker listener permits at most 16 connections, a ten-second initial-header deadline and a 60-second request deadline. The worker itself stops after 12 model calls and eight tools per response. Malformed or unavailable provider tool names are refused through a local placeholder; invalid or duplicate call IDs are normalized together with their paired replies. Tune container resources with limits: { memoryMb, cpus, pids } and the turn deadline with turnTimeoutMs (1 to 600 seconds). These are not billing quotas, global concurrency limits or disk quotas. Apply provider spend limits, ingress rate limits, a per-workspace filesystem quota, and host capacity controls before exposing a busy public channel. Host callbacks that ignore cancellation cannot be forcibly undone; write them to honor context.signal. An abrupt host crash can leave an isolated container behind; inspect and remove only stale containers prefixed roundtable-sandbox- belonging to this installation before restarting.

Memory and scope

memory_set, memory_get, and memory_remove run inside the sandbox and accept a speaker or channel scope. The current speaker selects the speaker namespace; a tool argument cannot select somebody else. Channel notes are shared within that workspace; other channels and host agents do not see them. Entries are persisted as JSON, capped at 256 entries, 2,048 characters per value and 512 KiB total. Up to ten settled user/assistant exchanges persist as channel conversation context; raw tool transcripts are not retained. History entries are capped at 8 KiB of JSON-encoded text, system-prompt memory previews at 4 KiB per scope, and current user context at 48 KiB. Full notes remain in the store and can be retrieved or removed by key with the memory tools. Model request context stays below 192 KiB by dropping old text history or complete tool-call groups, not partial tool transcripts. The worker marks truncated text; bounded tool descriptions and tool results cannot grow a later turn past the broker's body limit. Treat every channel's history and memory as potentially visible to all guests in that channel. Speaker namespaces prevent accidental tool cross-talk, not access by code that compromises the shared channel container. Never put confidential data in a guest workspace. startFresh resets history on the next successful turn and preserves memory; a pending reset is process-local and does not survive host restart. Turning mode off does not erase workspace files.

The default sealed mode is text-only and uses a minimal Chat Completions agent loop rather than loading a full Pi session inside the container. Attachments are not downloaded; the agent is told they are unsupported. It does not load skills or extensions and has no shell, schedules, delegation, image tools, personas, or access to the host runtime.

Explicit Pi/subscription mode

PiSandboxRuntime is a separate, explicit opt-in API; sandbox() and its sealed defaults do not change. Use it behind your own channel claim/store when you need existing Pi JSONL sessions, personas, subscription auth, media, or host-scoped tools. There is no automatic access to the owner's agent or global tool registry. This mode supports a Pi claude-bridge model through a host Anthropic OAuth broker, including streamed messages, token counting, custom tools and per-turn thinking. Install compatible @earendil-works/pi-coding-agent, typebox, and pi-claude-bridge (including its bundled Claude Agent SDK runtime) in the worker dependency image. MCP profiles additionally require pi-mcp-adapter. The host requires the core's SDK dependencies; the bridge is loaded only by the opt-in worker, never by host setup. Other model transports are not implicitly proxied by this broker.

Host options and hooks

| Option | Meaning and scope | | --- | --- | | partyDir, image | Dedicated private, host-owned directory and operator-built Pi image. | | profiles | Host allow-list mapping profile names to fixed Anthropic model and optional MCP server names. | | oauthToken({ channel, speaker }) | Host-only credential getter called before each model request with the bound turn's channel and speaker, so a host can use a different subscription per channel or speaker; supports refresh without container credentials. A zero-argument getter still works. Returning undefined or an empty string fails the call; no other credential is used. | | memory.promptBlock(channel, id, name), memory.visibility | Context at most 100,000 characters; private to the admitted reader by default. Explicit "shared" declares party-wide facts public and avoids tainting reader records. Database implementations remain host adapters. | | effort.judge(text, { level }) | Host-selected low, medium, high, or xhigh; the previous channel choice is retained for the next judgment. | | tools.names, tools.call | Explicit host tool allow-list and callback receiving fixed channel/profile/speaker plus cancellation signal. | | mcp.servers, mcp.token() | Fixed server URLs and tool-name allow-lists; host credential getter. | | upstream, fetchImpl | Trusted Anthropic endpoint and test transport override, not guest inputs. | | allowHttpMcp | Opt-in cleartext trusted MCP endpoints; avoid it unless your deployment protects that network. | | timeZone | Explicit worker time zone; default UTC. | | containerPrefix, labelChannel, labelProfile | Operator compatibility names for existing containers, not guest data. | | driver | Trusted local Docker driver or offline fixture; no remote bind mounts. | | logger | Host logger: failed turns with their cause, upstream failures (channel, status, latency, error body cut to 2,000 characters with credentials masked), a timed-out worker's last 200 log lines, and every compaction. | | compaction | Optional host compactor (PiCompactor), see Compaction. | | startTimeoutMs, turnTimeoutMs | 1–600 seconds; defaults 90 and 600 seconds. | | maxCalls, maxOutputTokens | Default 128 credential-bearing calls and 128,000 output tokens per model call; configurable 1–1,000 calls and 1,025–200,000 tokens. |

runTurn({ channel, profile, turnId, author, text, images, signal? }) returns text and bounded byte-backed reply files. start, stop, status, startFresh, sessionsDir, attachmentDir, and stopBrokers support trusted channel lifecycle adapters. The host binds identity immediately before enqueueing a turn and revokes it when the turn settles. Guest author, channel, target, and credential fields cannot replace host tool identity. Callbacks must still validate input and enforce per-person quotas, memory authorization and cancellation. Optional person/notes/moments tools can use an existing PostgreSQL store without copying the connection, schema, migration ledger or owner memory into the image. Schedules can be host tools with a declared channel-local background target and host-bound author; no scheduler is enabled automatically.

Principal migration in 0.9

Follow Migrating to 0.9 for the core upgrade. The sandbox claim ignores an author with no resolved tier; guests need core access admission, not an implicit owner fallback. SandboxRuntime.runTurn requires a host-bound speaker with principalId, and credential/host-tool hooks receive that id; the sealed worker protocol still receives only the actor id and name. For the separate PiSandboxRuntime, pass author: { id, name, principalId } from the admitted speaker: id remains the actor id and principalId is the reader used for private-history projection. It is optional for compatibility, falling back to id only for integrations whose actor id already is the principal id. Never take it from guest tool arguments. This does not isolate a shared channel's workspace by principal: guests in that channel still share its raw files and stored history. The sealed default's channel-shared memory contract is unchanged. Per-person model credentials remain the host integration's responsibility, not a guarantee of the core release.

Private tool exchanges in Pi mode

The container creates its own Pi session, not the core's SessionFactory; the worker now explicitly applies core memoryProjection to requests and privateCompaction before summaries. Host tool callbacks may return PiToolResponse with privateTo?: string, a principal id:

const principalId = context.speaker.principalId;
if (!principalId) throw new Error("Private tools require a bound principal");
return { ok: true, text: "private facts", privateTo: principalId };

The broker validates and preserves the field; the worker persists it as details.privateTo on the Pi tool result, including tagged error results (ok: false). PiSandboxRuntimeOptions.memory.visibility declares whether promptBlock is "private" (default) or "shared". A private nonempty prompt block taints that turn's reader record even without a tool call; an explicitly shared block does not. A host with party-wide facts must set memory.visibility: "shared", including when upgrading a 0.8 integration, or another participant's later claude-bridge turn is refused. Shared visibility applies only to that prompt block; a tool returning privateTo still creates a private exchange. For another reader, the call's arguments and result become placeholders while their call id/name pairing stays valid. The principal itself still sees the original exchange. This applies to any custom tool name, not just core memory tools; remember_person, recall_person, and forget_person must be tagged by the host when their results are private. Untagged custom exchanges are public. Both Pi's summary and the host compactor receive no tagged private exchanges, including the kept tail sent to the host, and no earlier prompt memory or reasoning. Calls/results split across compaction boundaries are paired for redaction. The bridge provider bypasses request projection by replaying its own history: before calling a provider, the worker refuses a bridge turn when its raw persisted branch records private memory carried for another reader, or holds an explicit private exchange hidden from that reader, even after Pi compacted it. Reader records cover private prompt blocks and their retained reasoning without requiring a tool call; memory exchanges, including malformed/truncated/unanswered built-in calls, belong to their turn's reader. A turn carrying no private memory never blocks another participant merely for speaking, so explicitly shared party-wide prompt blocks with public tools remain usable across A → B → A turns. Unlike the core host guard, sandbox has no primary-owner compatibility exception for untagged old built-in memory exchanges. Existing summaries are not retroactively scrubbed. Start fresh before allowing new readers into histories previously summarized without this protection, and rebuild the worker image with the upgraded core and sandbox together. This is exchange isolation, not general secret-flow prevention or a hostile-container boundary: public replies, untagged tools, raw workspace files, and attachments/reply files remain shared. Do not put confidential data in the guest workspace.

Worker capability handshake

The trusted Pi worker sends POST /worker/ready with { capabilities: { privateTo: true, readerRecords: true }, privateHistory: <boolean> }. privateTo declares tagged call/result request projection and private compaction support; readerRecords declares persisted per-turn readers, prompt privacy, and raw-branch bridge refusal. privateHistory reports retained private history, including compacted turns, when a worker reconnects to a restarted host. A missing or empty ready body means an older worker, not implicit support; malformed capability declarations are refused. The host requires both capabilities only when a turn carries a nonempty private prompt block, the worker reports retained private history, or a host tool actually returns privateTo. Merely configuring tools does not require capabilities, and old images can still serve empty or explicitly shared prompt blocks with only public tools. If a host tool first returns privateTo to an incompatible image, its payload is not sent to the worker: the host fails the whole turn and blocks subsequent model calls, even if the old worker catches tool errors. The error names the exact configured image to rebuild with the installed core and sandbox packages; keeping the same local image tag is not proof that it has been rebuilt. A worker reconnecting without capabilities also invalidates an already queued private turn. For direct PiSandboxBroker integrations, supply workerImage so errors name that image; PiSandboxRuntime supplies its image automatically. This handshake detects mixed trusted versions, not a compromised worker that lies about its capabilities. It does not sanitize historical data written by an old worker; start fresh before sharing previously unsafe private history.

Compaction

Worker sessions compact with the core's tiers, as the host's own sessions do: a model whose window leaves more than 300,000 tokens compacts at 300,000 through the host compactor, and past 500,000 through Pi's own summary, whose model calls go through the broker like any other. A compaction that left the context within 50,000 tokens of its threshold moves the next one to the hard ceiling, so it does not repeat on the next request.

The host compactor runs on the host, where the worker cannot reach (a remote compaction service, say):

new PiSandboxRuntime({
  // ...
  compaction: {
    engine: "my-compaction", // recorded as details.engine of its compactions
    compact: async (request, { channel, signal }) => {
      // request: PiCompactRequest; return a PiCompaction, or undefined for Pi's summary
    },
    timeoutMs: 120_000, // optional; default the smaller of 120 s and a third of turnTimeoutMs
    maxRequestBytes: 32 * 1024 * 1024, // optional; default 32 MiB
  },
});

PiCompactRequest carries Pi's preparation: reason, tokensBefore, firstKeptEntryId, isSplitTurn, messagesToSummarize, turnPrefixMessages, keptMessages (the messages from firstKeptEntryId on), previousSummary?, customInstructions?, readFiles and modifiedFiles. A PiCompaction is Pi's CompactionResult: summary, firstKeptEntryId (the request's), tokensBefore, estimatedTokensAfter? and details? (an object; the broker sets its engine). A compactor that returns undefined, throws, answers out of shape, outlasts timeoutMs or half the time the turn has left, whichever is shorter (its signal aborts), or gets a request over maxRequestBytes falls back to Pi's summary, and the host logs the reason. The timeout may be at most half of turnTimeoutMs, so Pi's summary keeps time to run. A turn may ask the host for three compactions and send sixteen compaction reports; later compactions fall back to Pi's summary, and while a compactor that ignored its signal still runs, a new request falls back too. Without compaction the worker registers no compaction handler; the tiers and Pi's summary still apply. jevCompactor({ logger }) from pi-roundtable/kit is a ready compactor through Jev, the one the core's hosts use: compaction: jevCompactor({ logger }). Compactions are written to the session file in the channel workspace, so they survive container removal and restarts.

The host logs, per channel: conversation compacted (trigger, engine extension or pi, tokensBefore, tokensAfter, contextAfter, the context the tiers measure, system prompt and tools included, and nextCompactionAt), compaction failed, compaction skips the extension for Pi's summary past the ceiling, and compaction falls back to Pi's summary with its fallback reason.

Worker content and files

The trusted image exports workerContent(profile): PiWorkerContent from /app/worker/content.ts. Its model: { provider, id } selects the installed Pi model; align it with the host profile's Anthropic model. Optional prompt supplies persona/profile text, skillsDir loads a skill index and bounded read_skill, brokerTools supplies tool descriptions/schemas, and toolNames plus extensions(turn) declares local operator extensions. Skill tool gates remain active until the corresponding skill is read; reads are confined to the baked-in skills tree, at most 256 KiB per file and 40,000 returned characters. Automatic extensions, context files, prompt-template discovery and built-in shell/read/write/edit tools are disabled. App dice/TRPG tools belong in extensions, not in a sandbox fork. contributionExtension wraps public core ToolContributions, collects their ToolTurn.attachFile output through withReplyFiles, and writes bounded worker outbox files. Use official drawing contributions this way instead of copied renderers; content and assets stay operator-owned.

collectPiAttachments downloads at most ten attachments, 25 MiB each and 50 MiB total, using controlled fetch. It creates files exclusively through a no-follow directory descriptor and prepares up to four images from downloaded bytes, never by reopening guest paths. Its prepareImage hook can use core prepareImageBytes; native decoders remain a trusted host boundary, and compressed image dimensions can require additional resource controls. Its fetchImpl override is for trusted offline fixtures only. Optional describeFailure(error) supplies application-owned wording, never raw host exception details. Rich turns accept at most eight PNG/JPEG/WebP/GIF images, 20 MiB each and 64 MiB decoded total. Current text, speaker-memory context and final text each have a 100,000-character limit; speaker identifiers/names have 256-character limits. Reply files use the core limits: ten files, 10 MiB each and 50 MiB total, credential-free base64 on the broker wire. No arbitrary host file path is attached or reopened. Apply a channel filesystem quota: persisted attachments, outbox files and Pi sessions have no automatic retention policy or total disk quota.

Build the dependency image with worker/Dockerfile.deps.pi and the installed node_modules directory as context. Build the runtime image with worker/Dockerfile.pi, --build-arg DEPS_IMAGE=<your-dependency-image>, and an application release tree containing trusted assets, persona, shared, and worker content. The runtime entry is the installed package's worker/pi-main.ts; no application runtime copy is needed. Never include real auth files or host source in those content directories.

Pi isolation and continuity

Pi containers remain network-none, non-root, read-only-root, capability-free and no-new-privileges. They have 1.5 GiB RAM with no additional swap, one CPU, 256 PIDs, and a 512 MiB temporary filesystem. Their logs go to journald tagged sandbox/<channel>, so they outlive the container; the Docker daemon must then run under systemd with journald, or containers fail to start. journald bounds the lines through its own rate limit and SystemMaxUse, not per container; PiDockerContainerDriver's third argument, { log: { driver, options } } (or a function of the channel), names another Docker log driver. The worker logs model errors, broker call failures, tool failures and compactions to stdout as JSON lines. Only the channel workspace (writable) and host-created broker directory (read-only) are mounted. Worker-initiated /worker/ready, /worker/next and /worker/result requests transport turns and byte-backed replies; the host never connects to a guest-created socket or reads guest reply paths. Only one idle long poll, one admitted turn and one incoming reply packet per channel are allowed. Reply packets are refused before body reading outside that turn and are cancelled with it. The worker re-announces readiness after host restart; cancellation force-removes the exact channel container. The broker has 16 connections and a ten-second header deadline. The rich listener streams responses with backpressure, so server-sent events reach the worker as they are produced; it limits silence (120 seconds with no request-body read or response chunk), not total time. The turn deadline (default 600 seconds) is the hard bound for every model, MCP and host-tool call. Credential-bearing calls have four in-flight slots, 96 MiB input bodies and 50 MiB upstream-response limits. Worker replies have a 72 MiB encoded packet limit. Budgets are not streaming memory or billing quotas.

Only POST /anthropic/v1/messages and /anthropic/v1/messages/count_tokens (optionally ?beta=true) use the model credential. The broker rebuilds model traffic as ordinary text/base64-image/custom-tool blocks, fixes the model and output limit, preserves supported thinking/effort but never above the host-judged level (the worker may ask for less, not more; adaptive thinking without an effort is capped below the model default for low and medium), and refuses native server tools, remote media, remote schema references and unrelated account routes. MCP initialization, initialized notification, ping, tool listing and explicitly listed tool calls are supported; other RPC methods and batches are refused. Before the first admitted turn, a 90-second, 32-call metadata-only startup scope permits eager MCP initialization/listing but never tool calls or model calls. That scope is revoked on the first bind. Upstream redirects are refused, headers are rebuilt, and bounded response streams reject obvious credential reflections across chunk boundaries. Trusted model/MCP services must not deliberately encode credentials; reflection filtering is defense in depth, not a guarantee against arbitrary encodings.

Sessions keep partyDir/channelSegment(channel)/workspace/sessions, with /workspace as Pi's session cwd. The same JSONL files are continued by SessionManager.continueRecent; no database migrations or workspace renames are performed. A host-only .channel-key record beside the workspace prevents legacy channelSegment collisions across every lifecycle/session/attachment operation; include it in backups. Keep the complete broker socket path within 100 characters. startFresh archives top-level plain .jsonl session files after the container is removed, replacing any symlink the guest planted at sessions, sessions/archive or attachments instead of following it; database memory stays intact. Profiles, database tables, migration receipts, quotas and ingress routing remain the application's trusted adapters. All guests in a channel share that channel's session/workspace; speaker hooks prevent tool identity spoofing, not confidentiality against compromised code in the same channel.

Controlled fetch and scoped delegation

safeFetch(url, { signal?, timeoutMs?, maxBytes?, maxRedirects? }) allows credential-free HTTP(S) only. Every hop resolves all addresses and refuses private, loopback, link-local, metadata, multicast, reserved and transition/documentation ranges, including encoded IPv4 and IPv4-mapped IPv6. The actual socket lookup is pinned to a vetted address while HTTPS verifies the original hostname; environment proxies and a second DNS resolution are not used. Connection failures may fall back only within that already-vetted list, with a three-second TCP/TLS connection deadline per address and the same whole-request deadline. Every redirect is checked again, including redirects to the same hostname after DNS rebinding. Defaults are 60 seconds, five redirects, and 5 MiB for both wire and decoded body; gzip, deflate and Brotli decoding are bounded too. Allowed ranges are 1 millisecond–120 seconds, 0–10 redirects and 1 byte–32 MiB bodies. resolve and transport overrides are trusted test seams, never guest inputs. SafeFetchResult supplies bytes and the final URL; adapt those bytes to your HTML/PDF parser instead of handing an unchecked URL to a library that fetches again. followRedirects: false returns a redirect response instead of following it, so a host that drives a library's own redirect handling can send each hop back through safeFetch and keep every hop vetted and pinned; headers sets trusted host request headers (never guest input; accept-encoding stays fixed). assertPublicUrl(url) refuses a URL that is not credential-free HTTP(S) or does not resolve only to public addresses. Use it before handing a model-supplied URL to a third-party reader; it does not pin a later connection, so host-side fetches of that URL should still use safeFetch.

ScopedSandboxDelegator fixes one declared target, channel-local report destination and host-bound author, with no owner/agent dispatcher or origin thread. Its default limit is two jobs per channel, 4,000 task characters, 80,000 report characters and ten minutes. A refusal reads "title and task are required", "the task is N characters; keep it within 4000" or "this channel already has N delegated tasks running; wait for one to report back". A failed job reports its error message scrubbed of credentials and bounded with scrubDiagnostic, or "the worker ran out of time" after the deadline. Configure maxRunning (1–10) and timeoutMs (1–1,200 seconds) explicitly when preserving an application's existing limits. A title is limited to 200 characters; maxTitleChars changes that, maxReportChars replaces the 80,000-character report bound and diagnosticChars the 600-character failure reason (each a whole number of at least 1, or Infinity). run(task, context) receives only bound channel/author/signal, and deliver(job, result) posts through the application's background-report adapter. runningChannels, idle and dispose support host lifecycle handling; jobs are process-local and are cancelled on disposal. SandboxResearchWorker is an optional host subscription adapter with explicit modelRuntime, agentDir, workDir, model, thinking, search, and extractFetched options. It creates an unsaved Pi session with only web_search and controlled fetch_content, no shell, host memory, skills/context discovery or owner tools. extractFetched receives already bounded, pinned-fetch bytes and must not re-fetch their URL. Alternatively fetchContent(url, signal) replaces the built-in fetch plus extractFetched with a host-owned fetch-and-extract; the host is then responsible for refusing unsafe and private addresses and for bounding time and size. Or tools replaces both built-in tools with the host's own: extensionPaths (installed Pi extension packages, such as pi-web-access), extensionFactories (for example a guard that vets each call before it runs), the toolNames that stay active, and an optional prompt; search, fetchContent and extractFetched are then unused. scope(run) wraps the whole session so a host can bind a fetch guard to this run, and aborted words a deadline stop (default "the worker ran out of time"). One of search with a fetch option, or tools, is required. Host search/model credentials stay in the trusted host process, and report delivery must remain in the declared guest channel. These hooks broaden the sealed threat model: review every adapter, apply provider spend limits and host quotas, and never substitute an unrestricted default delegation worker.

Precheck scripts

precheckScriptRunner lets agents write a schedule's precheck themselves (pi-roundtable 0.7.11 or later): a short JavaScript module that decides, before the scheduled turn, whether the agent is woken at all. The core stores and parses the script but never runs it; this runner runs it in a sealed container, one per run. Register it from a trusted plugin of your own:

import { PRECHECKS, definePlugin } from "pi-roundtable";
import { precheckScriptRunner } from "pi-roundtable-sandbox";

export const precheckScripts = definePlugin({
  name: "precheck-scripts",
  setup: ({ services }) => {
    services.get(PRECHECKS).useScriptRunner(precheckScriptRunner({
      image: "pi-roundtable-sandbox:pi",
      runRoot: "/tmp/roundtable-precheck",
      // The host's non-root user, who owns runRoot; root is refused.
      uid: 1000,
      gid: 1000,
      // Never more than the schedule's agent may call itself; [] leaves the script no way out.
      grant: ({ channel, target }) => grantsFor(channel, target),
    }));
    return {};
  },
});

grant({ channel, target, tier }) returns the PrecheckMcpServers a script for that schedule may call: { name, url, tools, token? }. tier is the tier the run is for, its schedule's capped at what its creator holds now (or the asker's, when schedule_list describes what a script may call), so grant lower tiers less. Each run reaches only the granted tools that were approved with the script: the core reads a script's calls when it is saved, and saving one that calls a tool the host's hold rules hold waits for the owner's approval. toolName(server, tool) gives the name those rules know a tool by, the name the host's own agent calls it by; the default is the tool's own name, so pass it when your agent sees MCP tools under another name, such as ${server}_${tool}. The script calls a server by name, only the listed tools, and only with single tools/call requests; every other server, tool, method, query, and extra credential is refused, and refused calls count against the run's budget (maxCalls, default 16). url is a fixed HTTPS Streamable HTTP endpoint answering with JSON or one SSE event; token is read on the host for each call and inserted by the broker. An answer that carries it, plainly, inside JSON text, or base64-encoded, is refused; this catches an echo, not an upstream set on leaking it, so grant only upstreams you trust. Calls run one at a time; a second call while one is pending is refused. Neither the endpoint nor the credential reaches the container.

Each run gets:

  • The same sealed docker run as a guest turn: --network none, a read-only root, all capabilities dropped, no-new-privileges, the host's non-root user, 256 MiB, one CPU, and 32 processes by default (limits).
  • A fresh broker socket and an empty workspace under runRoot, both removed afterwards; the workspace is mounted read-only, so a script writes only to the container's 64 MiB /tmp.
  • When the host stops, the scheduler aborts running scripts and waits for their containers to be removed.
  • The worker worker/precheck-main.ts as its entrypoint (PRECHECK_ENTRYPOINT, the Pi image's layout; pass entrypoint for another image, such as ["bun", "/app/worker/precheck-main.ts"] for worker/Dockerfile's).
  • At most timeoutMs (default 60 seconds); on timeout the container is killed and the turn wakes with the error.

The script is export default async ({ mcp, firedAt, timeZone, today, schedule }) => result, where result is { wake: false, note? } or { wake: true, context }. today is the date in the host's time zone, so a script never reads a UTC date by mistake. mcp.call(server, tool, args) returns the MCP tool result and mcp.json(server, tool, args) its structured content or its first text content parsed as JSON; a tool error throws. A throw, a wrong answer, or a timeout wakes the turn with the error. The runner's describe tells the model this contract and what it may call, through schedule_list.

Development and verification

From the pi-roundtable repository root:

bun install --frozen-lockfile
bun run --cwd packages/sandbox test
bun run --cwd packages/sandbox typecheck
bun run --cwd packages/sandbox lint

Offline tests cover credential swapping and reflection, route/header/media refusals, host-bound identity, host-tool and MCP allow-lists, real Unix transport, generated Docker arguments, routing precedence, cancellation/cleanup, persistence and memory scopes. The Docker integration test is skipped by default and requires a native Linux non-root host with local Docker:

cd packages/sandbox
SANDBOX_DOCKER_TEST=1 bun test src/docker.integration.test.ts

It builds the image and runs a turn against a fake model endpoint on the host, checking that only the broker receives the real test credential. It also runs a hostile PID 1 that ignores SIGTERM and verifies that the turn deadline removes that container. No live provider, Discord connection or database is needed. Shared CI runs offline checks and explicitly enables that Linux Docker test on its Ubuntu runner. The shared publish workflow repeats local checks and requires a version-matching v* tag for the core and all official packages. The owner performs the first sandbox publication and configures trusted publishing as described in workspace releases.