agent-neckbeard
v1.1.19
Published
Deploy AI agents to E2B and Daytona sandboxes
Maintainers
Readme
neckbeard
Neckbeard deploys your agent code into an E2B or Daytona sandbox and runs it behind a small persistent HTTP server inside that sandbox.
The intent is simple: your production process should not run the agent. The sandbox runs the agent, keeps its filesystem/process state, and exposes a private /invoke endpoint that neckbeard calls for each turn.
This is designed to work from short-lived request handlers and long-running workers, including Vercel Functions, Hatchet workers, and Trigger.dev tasks. See docs/CONSUMER_ENVIRONMENTS.md before changing lifecycle, timeout, streaming, or cleanup behavior for those environments.
The host package requires Node.js >=20.18.1 <21 || >=22, matching the supported range of its E2B runtime dependency. Its esbuild runtime dependency requires Linux kernel 3.2 or later or macOS 12 or later on those platforms.
The four primary usage patterns:
- Structured tasks: an agent runs once per background task and returns one validated output (Structured Tasks below).
- Attached sessions: a caller-owned session emits typed progress frames while work runs (Attached Sessions below).
- Direct sandbox access:
SandboxClientwraps E2B and Daytona behind one filesystem/command API, no agent required (Direct Sandbox Access below). - Durable sessions: an opt-in generated supervisor owns Pi inside Daytona and reconnects outward to a long-lived gateway (Durable Sessions below).
Agent.kind reports "task" or "session". The older mode: "run" | "stream" property remains as a deprecated compatibility surface.
What This Does
You write an agent:
import { Agent } from "agent-neckbeard";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const agent = new Agent({
sandbox: {
provider: "e2b",
template: "code-interpreter-v1",
},
inputSchema: z.object({ topic: z.string() }),
outputSchema: z.object({
title: z.string(),
summary: z.string(),
keyPoints: z.array(z.string()),
}),
envs: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
},
run: async (input) => {
for await (const message of query({
prompt: `Research "${input.topic}" and return JSON`,
options: { maxTurns: 10 },
})) {
if (message.type === "result") {
return JSON.parse(message.result ?? "{}");
}
}
throw new Error("Claude did not return a result");
},
});
const sandboxId = await agent.deploy();
const result = await agent.run({ topic: "TypeScript generics" }, { sandboxId });deploy() bundles your agent, uploads agent.mjs and the embedded runtime server to the sandbox, starts node server.mjs, waits for /health, and returns the sandbox ID.
run() validates input locally, POSTs it to the sandbox server, reads SSE harness events, validates the final output, and returns the existing { ok, executionId, output | error } result shape.
By default, the package infers the file to bundle from the new Agent(...) call stack. In frameworks that bundle server code before runtime, set sourceFile to the real agent module so deploy() does not rebundle a framework output chunk:
new Agent({
sourceFile: path.resolve(process.cwd(), "packages/my-agent/src/agent.ts"),
// ...
});When the right path differs between environments (for example a monorepo dev server versus a deployed worker), resolveAgentSourceFile(candidates, { errorMessage }) returns the first candidate path that exists and falls back to the bundled agent module when running inside the sandbox.
For Next.js/Vercel-style deployments, keep agent-neckbeard external to the server bundle and make sure the referenced source file is included in the production artifact:
// next.config.ts
import { withNeckbeardNextConfig } from "agent-neckbeard";
const nextConfig = withNeckbeardNextConfig(
{
// your existing Next config
outputFileTracingIncludes: {
// Include every localDirectories source tree used by deploy().
"/api/my-agent": ["./packages/my-agent/.agents/**/*"],
},
},
{
routeGlob: "/api/my-agent",
sourceFiles: ["./packages/my-agent/src/agent.ts"],
},
);
export default nextConfig;The helper traces files imported by those source entries by default, including package entry metadata that deploy() needs when it rebundles the agent at runtime. It preserves and merges existing outputFileTracingIncludes, such as the .agents/**/* entry above. A localDirectories.source is read by the deployed process, so its complete tree must be included in the production function artifact. Set outputFileTracingRoot when the agent source or local directory is outside the app root.
This repo tests that shape locally with npm run test:compat:next, which builds and runs a Dockerized Next App Router fixture without pushing to Vercel. The fixture also checks the runtime env contract, Node major, and traced standalone files so source-level tests do not mask deployment artifact problems.
Setup
npm install agent-neckbeardexport E2B_API_KEY=your-key
export DAYTONA_API_KEY=your-key
export ANTHROPIC_API_KEY=your-keyPick the sandbox backend explicitly with sandbox.provider. The public sandbox.template field maps to an E2B template or a Daytona snapshot.
Daytona sandboxes can also receive network policy options supported by the Daytona SDK. Use domainAllowList for domains and wildcard domains, or networkAllowList for comma-separated CIDR ranges:
new Agent({
sandbox: {
provider: "daytona",
template: "daytona-small",
domainAllowList: "*.example.com",
},
// ...
});Set networkBlockAll: true only when you want to block all outbound network access. Daytona rejects networkBlockAll: true combined with non-empty allow lists.
Structured Tasks
Use run when each invocation should produce one validated output:
new Agent({
sandbox: {
provider: "e2b",
template: "code-interpreter-v1",
},
inputSchema: z.object({ prompt: z.string() }),
outputSchema: z.object({ answer: z.string() }),
run: async (input, ctx) => {
ctx.logger.info(`Handling ${ctx.executionId}`);
return { answer: input.prompt.toUpperCase() };
},
});The run handler receives a context object with executionId, AbortSignal, environment variables, sandbox paths, and a logger:
run: async (input, ctx) => {
const notesPath = `${ctx.sandbox.homeDir}/data/notes.txt`;
const cwd = ctx.sandbox.agentDir;
// ...
};Use ctx.sandbox.homeDir and ctx.sandbox.agentDir instead of hardcoding /home/user if you want the same agent code to work on both providers.
Structured-task agents fit background workflows (Hatchet tasks, Trigger.dev jobs, queue workers). The usual shape is one sandbox per task:
const sandboxId = await agent.deploy({ fresh: true });
try {
const result = await agent.run(input, { sandboxId });
if (!result.ok) throw result.error;
return result.output;
} finally {
await agent.kill(sandboxId);
}deploy({ fresh: true }) keeps concurrent tasks from sharing one cached sandbox; see Reusing Sandboxes for the caching behavior.
The current 1.x structured-task configuration intentionally remains flat: top-level outputSchema and run define the task, and agent.run() executes it. Neither use of run is deprecated. A future API revision may introduce a noun-oriented task: { outputSchema, handler } configuration to parallel session, with a compatibility period for the current shape; no such change is scheduled.
Attached Sessions
Use session.handler when you want progress frames while the sandbox work is still running, such as relaying tool calls and assistant text to a UI or event log:
const agent = new Agent({
sandbox: {
provider: "daytona",
template: "daytona-small",
},
inputSchema: z.object({ prompt: z.string() }),
session: {
frameSchema: z.object({
type: z.enum(["status", "token", "done"]),
text: z.string().optional(),
}),
handler: async ({ input, emit }) => {
await emit({ type: "status", text: `Starting ${input.prompt}` });
await emit({ type: "token", text: "hello" });
await emit({ type: "done" });
},
},
});
const sandboxId = await agent.deploy();
try {
const session = await agent.createSession({ sandboxId });
try {
for await (const frame of session.prompt({ prompt: "hi" })) {
console.log(frame);
}
} finally {
await session.close();
}
} finally {
await agent.kill(sandboxId);
}An attached session is a local handle over the sandbox HTTP/SSE runtime. It supports sequential prompt() calls, one active turn at a time, stop() for the active turn, and close() for the local handle. Closing it does not destroy the reusable sandbox. Pass { signal } to createSession() to bind the complete local session lifetime to an external abort signal.
The previous server: { frameSchema, handler } configuration remains as a deprecated alias for session. Likewise, invoke(input, options) remains as a deprecated compatibility wrapper: it creates one attached session, streams one prompt, and closes the local handle afterward.
Reusing Sandboxes
Persist the sandbox ID if another process needs to call the same running sandbox server:
const sandboxId = await agent.deploy();
await saveSandboxId(sandboxId);
// Later:
const savedSandboxId = await loadSandboxId();
const result = await agent.run(
{ topic: "hello" },
{ sandboxId: savedSandboxId },
);Neckbeard rehydrates the sandbox endpoint and provider traffic token from the sandbox ID using the configured provider SDK. E2B traffic tokens are sent as the e2b-traffic-access-token header. Daytona preview tokens are sent as the DAYTONA_SANDBOX_AUTH_KEY query parameter. Consumers only need to store the sandbox ID and configure the same provider/API key in the process that reconnects.
deploy() caches the sandbox on the agent instance and returns the cached ID on later calls. Use deploy({ fresh: true }) to provision a new sandbox without touching the instance cache, which is useful when one agent instance serves concurrent runs that each need their own sandbox. The caller owns the returned sandbox ID: pass it to run()/createSession() and kill() it explicitly.
Environment Changes with Reused Sandboxes
envs are applied when Neckbeard creates the sandbox and starts the sandbox server. If the host app was missing a required value such as ANTHROPIC_API_KEY when the sandbox was created, the sandbox can remain healthy but unauthenticated even after you fix the host environment and redeploy the app.
Updating Vercel, Render, or 1Password environment variables does not mutate already-running sandboxes. Kill the old sandbox or deploy a fresh one and replace the persisted sandbox ID. Consumers that keep long-lived sandbox IDs should treat authentication failures such as Not logged in or Please run /login as a signal to discard the sandbox after verifying the host environment is now correct.
Direct Sandbox Access
Use SandboxClient when host code needs provider-neutral filesystem or command access without running an agent invocation, such as staging documents into a sandbox before an agent runs or pulling artifacts out afterward:
import { SandboxClient } from "agent-neckbeard";
const sandbox = await SandboxClient.create({
provider: "daytona",
template: "daytona-small",
domainAllowList: "*.example.com",
autoDeleteInterval: 30,
});
const documentsDir = `${sandbox.paths.homeDir}/documents`;
await sandbox.makeDir(documentsDir);
await sandbox.writeFile(`${documentsDir}/input.txt`, "hello");
const result = await sandbox.runCommand("ls documents", {
cwd: sandbox.paths.homeDir,
timeoutMs: 60_000,
});
const bytes = await sandbox.readFileBytes(`${documentsDir}/input.txt`);
await sandbox.kill();Reconnect to an existing sandbox with SandboxClient.connect({ provider, sandboxId }). create() accepts provider-specific lifetime options: timeoutMs on E2B, plus autoStopInterval and autoDeleteInterval on Daytona. Daytona measures autoDeleteInterval from when the sandbox stops; -1, the default, disables auto-delete. sandbox.paths exposes the same provider-neutral paths agents receive as ctx.sandbox. E2B supports setTimeout() for sandbox lifetime extension; providers without that lifecycle primitive throw a ConfigurationError.
SandboxClient.startDetachedCommand(), readSessionLogs(), stopSession(), and disposeSessionArtifacts() expose provider background-process primitives. Pass the complete opaque handle returned by startDetachedCommand() to every follow-up API; deprecated string overloads remain only for persisted handles from older releases and native provider sessions, while artifact disposal accepts managed current handles only. Detached prepare, release, cancellation, stop, and disposal failures use fixed bounded messages rather than reproducing provider output or SDK request options, which can contain launch environment values. Caller envs are serialized into one bounded internal provider value instead of becoming active variables in the lifecycle preparation shell. Before an SDK-selected outer shell starts (including E2B's login Bash), every control request supplies a trusted PATH, TMPDIR, and LANG, an inaccessible HOME, and neutral Bash/sh startup and xtrace controls. Preparation and the permanent reaper then run under fixed PATH, HOME, TMPDIR, and LANG values. A generic launch validates and erases the anonymous handoff descriptor before applying caller variables only to the requested /bin/sh payload; its payload HOME defaults to sandbox.paths.homeDir for compatibility, while an explicit caller HOME wins. Supervisor mode passes the descriptor to runtime entry for its existing strict hydration.
Captured stdout and stderr are bounded, best-effort diagnostics only. Neckbeard reads them internally only in the two generic HTTP runtime startup paths, after a health check has already failed, to enrich the resulting DeploymentError; those paths give the diagnostic read a one-second local deadline, ignore a diagnostic failure, and redact every credential-named configured environment value plus configured values of at least eight characters before including available output. Redaction also suppresses the possible leading credential fragment created when a native provider tail begins inside a configured value, and the fully formatted internal diagnostic is bounded again to 8 MiB after sanitization. Shorter generic settings are not automatically redacted. A capture/append/rotation/marker failure disables further storage for that stream while its pump continues draining the FIFO, so diagnostic failure cannot remove the pipe reader or terminate the target with EPIPE. The durable supervisor and Mission Control never use these bytes as transcript, lifecycle, ownership, completion, or acknowledgement state. The public readSessionLogs(handle) API deliberately preserves raw captured output for compatibility: Neckbeard does not persist a historical secret dictionary that could reliably sanitize it, so callers must treat the result as sensitive diagnostic data and avoid exposing or retaining it without their own redaction. It reads stdout and stderr independently, so the returned pair is not one atomic cross-stream snapshot and the streams may represent different instants. Each returned stream contains at most the newest 8 MiB accepted by its read, including deprecated native-provider results. The detached envelope accepts only strict UTF-8; binary or otherwise invalid text fails closed to empty diagnostics instead of expanding through replacement characters beyond that byte bound. Legacy Daytona log leaves are bound to owner-only single-link descriptors before their bounded read.
For the V2 capture protocol, each writer brackets a chunk append or rotation with atomically replaced ROTATING and STABLE generation markers. A reader makes a bounded number of optimistic marker-before/read/marker-after attempts. On each attempt it opens the current and optional previous segments, validates their owner-only single-link regular-file paths, binds the read to those descriptors, and accepts the payload only if the marker is unchanged. That check is a best-effort race detector, not a public guarantee of an atomic or perfectly coherent diagnostic snapshot. A concurrent writer or unsafe, malformed, or unstable state can make the read fail closed and return empty diagnostics; even a successful read may be stale relative to output still buffered in the pipe, and a process or sandbox crash can lose a final fragment that was never published. Recovery from a V2 ROTATING marker or a current segment missing after an interrupted current-to-previous rename occurs only when the current PROCESS_V3 lifecycle record proves quiescence through its exact completion marker. An older started PROCESS_V2 record has no equivalent proof and may therefore fail diagnostic recovery. An interrupted legacy rotation fails closed because its copy/truncate phases are ambiguous. Persisted V1 STABLE markers retain the compatible bounded double-marker reader without requiring flock, with the same descriptor-to-path validation. Current readers and writers do not take a per-stream log flock; empty legacy .snapshot.lock leaves may still be created solely for older readers during a rolling upgrade, but current code treats them as inert compatibility artifacts.
A replacement-capable generation independently requires util-linux flock for its outer launch, decision, and replacement fence. Optional disposeSessionArtifacts() also requires flock for its destructive generation fence. Generic permanent launch, stop, and diagnostic log reads do not require it; a generic launch needs flock only if the caller later requests disposal. Stop remains exact-handle and PID-birth fenced without taking that lock. Diagnostic log reads do not acquire it either, even when its compatibility leaf exists: they identity-check the generation and bind each bounded read to validated file descriptors, but a concurrent retirement or replacement may make the sample stale or fail. Pass a stable launchId to make a retry adopt the same prepared process, and use beforeRelease(prepared) to durably persist its complete process handle before the command may run. The callback must be idempotent because a later caller may adopt the launch. Neckbeard hashes launchId before using it in sandbox paths and binds adoption to a URL-free hash of the command, working directory, and environment names; environment values are never written to the lifecycle markers or command string. Those values are therefore immutable for one launchId: rotate credentials or any other value by minting a replacement attempt and launch ID.
E2B and Daytona publish an atomic release-or-cancel decision, wait for a durable started confirmation, and return only after release is confirmed. New detached launch and stop paths require Python 3 with Linux prctl and pidfd support. After validating the exact setsid leader, the permanent Python wrapper enables PR_SET_CHILD_SUBREAPER, durably publishes started, and only then spawns the diagnostic pumps and original payload through sh -c. A lost provider response can therefore adopt the same process without launching a duplicate. If an internal restart-capable leader dies before started, its absence proves that no logger or user workload was spawned and a locked retry can reclaim that incomplete generation. Once started exists, the wrapper remains the exact session leader, adopts orphaned descendants, waits through ECHILD (including its log pumps), writes a descriptor-bound exact completion marker, fsyncs it, and exits. Stop preserves that wrapper, opens a stable pidfd for each captured-session candidate before checking and signaling it, and reports success only after exact leader absence plus the completion proof. A descendant that creates a new session is not signaled; if the subreaper is still waiting for it, stop fails within its bounded deadline instead of falsely reporting quiescence. An exited started PROCESS_V2 record predates this proof and fails closed for current stop or replacement rather than claiming exact termination. Pre-V2 native handles retain their explicitly weaker compatibility behavior. Legacy e2b:<pid> handles cannot prove process birth or descendant ownership: their compatibility stop sends at most one provider signal and resolves only after E2B confirms the PID was already absent or its running-command inventory confirms that the numeric PID disappeared. It never signals that PID again while polling. These APIs are additive and do not change runCommand() behavior.
Generic startDetachedCommand() launch IDs remain permanently bound to their first released generation, even after a short command exits. Durable supervisor deployment adds an internal recovery policy: once the PROCESS_V3 subreaper has published its exact completion proof (or an exact unstarted leader is absent), reconciliation may replace that dead generation on the same attempt. Lifecycle controls remain generation-fenced. Diagnostic reads do not take that fence: they check identity before capture and again before returning, so a normal concurrent replacement race fails empty instead of returning the replacement generation, while the broader snapshot remains explicitly best-effort rather than atomic.
An application that reuses a sandbox can bound retained lifecycle state and diagnostics by calling stopSession(handle), optionally reading final logs, and then calling disposeSessionArtifacts(handle). Stop and log behavior are unchanged until that explicit destructive step. Disposal accepts an exact managed PROCESS_V3 generation only after either its exact leader is absent or no longer matches, no safe started marker exists, and the descriptor-bound quiescent leaf is still exactly empty, proving the workload never launched, or its exact started and completion markers both validate. It removes the exact launch directory, its identity-derived disposal recovery directory, any intact launch-key replacement-retirement directory that proves the same exact generation, and finally the generation lock. A mismatched, partial, or unsafe replacement-retirement directory fails closed instead of risking successor state. Disposal is idempotent when those exact artifacts are already absent. Absence during disposal is never process-exit evidence: callers must persist and retry stop confirmation first. After disposal, reads return no retained logs, stop can no longer prove the old generation, and the same stable launchId may create a new generation. Do not race generic same-launchId start with disposal; durable supervisor start and disposal share the generation lock, but lifecycle ownership still belongs in the calling control plane.
Durable Sessions
Use agent-neckbeard/supervisor when a persistent sandbox process should own Pi while a separate gateway and lifecycle worker may restart independently:
import { SandboxClient } from "agent-neckbeard";
import {
createDurableSessionArtifact,
deployDurableSessionArtifact,
} from "agent-neckbeard/supervisor";
const sandbox = await SandboxClient.connect({ provider: "daytona", sandboxId });
const artifact = createDurableSessionArtifact({
sessionId,
runId,
attemptId,
gatewayUrl: "wss://gateway.example.com/v1/supervisor",
installDir: `${sandbox.paths.homeDir}/.neckbeard/runtime/${attemptId}`,
stateDurabilityAnchor: sandbox.paths.homeDir,
stateDir: `${sandbox.paths.homeDir}/.neckbeard/sessions/${sessionId}`,
workspaceDir: sandbox.paths.workDir,
pi: {
provider: "anthropic",
model: "claude-sonnet-4-5",
providerCredentialEnv: "ANTHROPIC_API_KEY",
},
});
const supervisorProcess = await deployDurableSessionArtifact(
sandbox,
artifact,
{
envs: {
NECKBEARD_SUPERVISOR_CREDENTIAL: attemptCredential,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
},
beforeRelease: async (prepared) => {
await persistSandboxProcess(prepared);
},
},
);
// During terminal reconciliation, load the identity persisted above, stop the
// exact fenced process, then apply the application's sandbox deletion policy.
try {
const persistedProcess = await loadSandboxProcess();
await sandbox.stopSession(persistedProcess);
await sandbox.disposeSessionArtifacts(persistedProcess);
} finally {
await sandbox.kill();
}createDaytonaSupervisorSandboxTarget() adapts an already-connected @daytona/sdk sandbox to the same narrow deployment contract without taking ownership of that sandbox. Its optional beforeMutation hook revalidates external lifecycle ownership before path discovery, staging, bootstrap, each prepare request, and every cleanup/adoption request that lacks an exact returned identity; release authorization remains the beforeRelease callback, and release or cleanup of a captured exact identity deliberately bypasses the hook so the single-winner launch decision can finish after ownership changes. If any identity-less ownership check fails after an uncertain provider request, the stale caller issues no later cancellation or adoption request against the deterministic launch, leaving it same-attempt retryable by the current owner. Terminal reconcilers with the raw Daytona SDK can call stopDaytonaSupervisorSession(daytonaSandbox, persistedProcess, { beforeMutation }), optionally retain their own final diagnostics before that boundary, then call disposeDaytonaSupervisorSessionArtifacts(...). Those explicit terminal helpers depend only on Daytona's command transport, without launch-only HOME or working-directory discovery, and re-run beforeMutation immediately before every stop/disposal provider request and retry; a lost claim authorizes no later provider I/O. validateDurableSessionDeploymentEnvironment() exposes the same credential and bounded-environment preflight that deployDurableSessionArtifact() performs before sandbox I/O.
The artifact uses attemptId as its stable launch identity. The attempt credential must be an opaque 256-bit CSPRNG token encoded as canonical unpadded base64url (43 characters), matching Mission Control's credential generator; arbitrary passwords are rejected. Persist the sandbox ID and prepared process identity in the idempotent beforeRelease callback; returning from that callback is the authorization for the process to run. Replacing an exited generation invokes the callback with a new exact identity, which must replace the prior persisted identity; stale handles remain exactly fenced for stop, while diagnostic reads use before/after identity checks and may fail empty during replacement. A revision keyed by that high-entropy credential binds every replacement generation to the canonical launch environment without placing a dictionary-checkable value hash in lifecycle state, so rotating any value requires a replacement attempt instead of silently relaunching the old attempt with new credentials or configuration. A persistence failure is reported through the fixed, credential-safe Supervisor prepared identity persistence failed. message unless subsequent cleanup confirms that cancellation won. A confirmed cancellation proves the prepared generation cannot run and instead throws SupervisorLaunchCancelledError with stable code NECKBEARD_SUPERVISOR_LAUNCH_CANCELLED; its enumerable name and code remain usable when serialization preserves enumerable fields. A lost cleanup response is not classified as cancelled and remains safe to retry with the same attempt. If release already won, cancellation adopts the released identity instead of reporting a cancelled outcome. Terminal reconciliation must stop that persisted process identity and either delete the sandbox explicitly, as above, or apply an explicitly configured provider stop/delete policy. The supervisor embeds the pinned @earendil-works/pi-coding-agent SDK in-process, uses an outbound resumable WebSocket, attempt fencing, monotonic event IDs, a fsynced disk outbox, cumulative acknowledgments, command write-ahead deduplication, verified attachment materialization, normalized turn lifecycle, turn-boundary steering, heartbeat/reconnect, and idempotent interrupted-turn recovery. Its Daytona snapshot must provide Node.js 22.19.0 or newer. The artifact uploads graph-addressed staging files, then under one sandbox flock verifies their hashes, atomically publishes and fsyncs the final runtime/config/dependency files, and runs npm ci. After npm's graph and version checks, bootstrap creates a deterministic bounded manifest of every directory, regular file, and safe relative symlink in node_modules; it fsyncs regular files and directories bottom-up before atomically publishing and fsyncing that manifest, then re-verifies the finals and publishes the fsynced graph marker. The bootstrap lock remains necessary because a provider timeout does not prove the remote npm ci stopped; a retry may overlap the same shared installation. Bootstrap runs with an empty inherited environment, neutral npm user/global configuration, and no project .npmrc; failures expose only a bounded exit-code summary, never raw provider or install output. A marked retry skips npm ci only after verifying the final files and exact dependency graph plus a descriptor-bound read and full recomputation of the installed-tree manifest. An absent, malformed, unsafe, oversized, or content-mismatched manifest enters the existing locked repair path, while another graph fails without replacing it. Custom Pi coordinates require a caller-supplied matching credential-free lock. Credentials are never embedded in generated files or install commands; the gateway credential is removed before Pi loads, and the provider credential is moved into Pi's in-memory model runtime before tools run.
Each attempt's Pi JSONL history has a hard 64 MiB ceiling. Startup rejects a larger file or a legacy session version before the SDK parses or migrates it, and new prompts or steers are refused at 56 MiB so an already accepted turn has 8 MiB of logical headroom. Admission finishes with a synchronous retained-descriptor size check immediately before the Pi side effect. The pinned Pi 0.83 manager must remain in the append-only state established by the explicit session-file open; the guard serializes each pending JSONL line once, refuses a line that would cross the ceiling, writes those exact bytes through the retained descriptor, and verifies the exact size delta afterward. An unexpected private full-rewrite state fails before any write instead of estimating Pi's private in-memory history. Empty-file initialization is the only supported open-time rewrite; non-empty history must already use Pi session format version 3. Any append guard or postcondition failure terminally fails the attempt because Pi advances its in-memory tree before calling the persistence hook. Terminal history is file-fsynced plus parent-fsynced only while the canonical private leaf still matches the retained descriptor before and after that durability boundary. The 8 MiB margin is budgeting headroom for an accepted turn, not filesystem preallocation or a promise that every future record will fit; terminal/restart facts use the supervisor outbox's separate recovery reserve. Replace the attempt when prompt admission reaches this boundary.
For that cancellation outcome, JSON.parse(JSON.stringify(error)) retains the public name and code. Arbitrary error serializers are not part of the contract.
Replacement-capable release and adoption return a released handle only while the exact PROCESS_V3 leader is proven live under the generation lock. Exact completion proof remains safe to retry with the same attempt, allowing the completed generation to be retired and replaced. If an exact started marker exists but the leader is dead or ambiguous and no valid exact completion proof exists, deployment instead throws fixed-message SupervisorReleasedGenerationUnavailableError with stable code NECKBEARD_SUPERVISOR_RELEASED_GENERATION_UNAVAILABLE. The generation may have run, so this is neither cancellation nor transport uncertainty: the caller must fence and confirm termination of the whole sandbox, then create a new attempt or terminally interrupt the run rather than adopt or retry that released generation.
The previous createPiSupervisorArtifact() and deploySupervisorArtifact() exports remain as deprecated aliases.
See docs/SUPERVISOR.md for the complete launch and gateway protocol contract.
Configuration
import type { LocalDirectoryUpload } from 'agent-neckbeard';
new Agent({
sandbox: {
provider: 'e2b' | 'daytona',
template: string,
// Daytona only:
networkBlockAll?: boolean,
networkAllowList?: string,
domainAllowList?: string,
},
inputSchema: ZodSchema,
maxDuration?: number,
dependencies?: {
apt?: string[],
commands?: string[],
},
files?: [{ url, path }],
localDirectories?: LocalDirectoryUpload[],
claudeDir?: string, // deprecated; use localDirectories
envs?: Record<string, string | undefined>,
build?: {
external?: string[],
autoDetectExternal?: boolean,
},
// Structured-task agents require:
outputSchema: ZodSchema,
run: (input, ctx) => Promise<output>,
// Session agents require this instead of outputSchema/run:
session: {
port?: number,
frameSchema: ZodSchema,
handler: ({ input, ctx, signal, emit }) => Promise<void>,
},
});The files option downloads files into the sandbox before the server starts. Relative paths resolve from the sandbox home directory: /home/user on E2B and the sandbox user's home on Daytona (/home/daytona on default snapshots).
LocalDirectoryUpload is exported by the package and has this shape:
interface LocalDirectoryUpload {
source: string;
destination: string;
}localDirectories recursively copies consumer-selected local directories beneath ctx.sandbox.agentDir during deploy(). Relative sources resolve from the deploying process's current working directory; absolute sources are also supported. Nested paths and dotfiles are included, text and binary files retain their exact bytes, and parent directories are created as needed. Empty directories are not preserved.
Each destination is relative to the sandbox agent directory. For example, this Pi-oriented configuration produces <sandbox agentDir>/.agents/skills/... on both E2B and Daytona:
const agent = new Agent({
localDirectories: [
{
source: "./packages/my-agent/.agents",
destination: ".agents",
},
],
// ...structured-task or session-agent configuration
});Copying skills to .agents/skills does not guarantee that Pi or another harness will discover them automatically. If the harness working directory is elsewhere, pass ${ctx.sandbox.agentDir}/.agents/skills to it as an explicit skill path. Neckbeard does not detect harnesses or change their discovery and trust settings.
Before creating a sandbox, Neckbeard validates and enumerates every local upload. A source must exist and be a directory. localDirectories rejects symbolic links anywhere in a source tree, with the local path in the error. Empty destinations, ., absolute destinations, traversal outside the agent directory, and duplicate sandbox targets are rejected with ConfigurationError; this includes collisions with another entry or with claudeDir.
For Next.js/Vercel and other bundled deployment environments, ensure each source tree is present in the runtime artifact. Include it with withNeckbeardNextConfig as shown above, with raw outputFileTracingIncludes, or with the equivalent artifact-inclusion setting for the host framework.
claudeDir is deprecated but remains functional for backward compatibility. It uploads the contents of a local .claude directory to <sandbox agentDir>/.claude, preserving existing Claude Agent SDK behavior. Replace it with an explicit localDirectories mapping:
const agent = new Agent({
localDirectories: [
{
source: "./my-project/.claude",
destination: ".claude",
},
],
// ...agent configuration
});The envs option passes environment variables to the sandbox. Undefined values are filtered out:
const agent = new Agent({
sandbox: {
provider: "e2b",
template: "code-interpreter-v1",
},
envs: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
MY_API_KEY: process.env.MY_API_KEY,
},
// ...
});Some packages cannot be bundled because they spawn child processes or have native modules. The Claude Agent SDK is like this. These packages are automatically marked external and installed with npm in the sandbox. The build option tunes this: build.external adds package globs to mark external, and build.autoDetectExternal: false disables the native-module auto-detection.
Cleanup
A successfully deployed sandbox remains caller-owned and runs until you kill it or the provider's lifetime settings expire it. If deploy() creates a sandbox but fails before returning its ID, Neckbeard reclaims that incomplete deployment so it does not leak an unreachable provider resource. Kill every successfully deployed sandbox, as in the workflow example above. kill() accepts a sandbox ID. If omitted, it kills the sandbox cached on the agent instance.
Errors
All errors extend NeckbeardError. ConfigurationError covers invalid agent config, missing provider API keys, and provider features a sandbox does not support. DeploymentError covers sandbox creation, dependency install, and runtime server startup failures. After a runtime-server health-check failure, the two built-in startup paths include bounded best-effort captured logs when available, redact credential-named configured environment values and configured values of at least eight characters (including a possible tail-boundary fragment), and preserve the original health error if the diagnostic read fails or exceeds its local deadline. ValidationError wraps input/output schema failures with the parser error as cause. ExecutionError represents agent handler failures.
run() never throws; it returns { ok: false, error }. Attached session.prompt() throws. The deprecated invoke() wrapper preserves that throwing behavior.
Testing
npm run checkThe local check runs typecheck, repository lint checks, formatting hygiene checks, unit tests, generated runtime verification, CJS build verification, and package dry-run validation.
Jest drives both the fast local suite and the live smoke suites. Each live suite automatically skips when its provider key is not set:
npm run test:e2e:e2b
npm run test:e2e:daytonaOptional environment variables:
NECKBEARD_E2E_TEMPLATEto override the defaultcode-interpreter-v1test templateNECKBEARD_DAYTONA_TEMPLATEto override the default Daytona smoke snapshot (daytona-small)NECKBEARD_E2E_TIMEOUT_MSto increase the live test timeout for slower environments
License
MIT
