@tangle-network/agent-provider-tangle
Wraps @tangle-network/sandbox as an AgentEnvironmentProvider.
The Sandbox peer range is >=0.60.27 <1.0.0; the minimum version exports the shared terminal projection.
Terminal result and done events use the Sandbox SDK's projectAgentTerminalRecord summary. Cumulative tool history stays in native evidence; bounded approval identity survives projection, and malformed or conflicting approval sources fail closed. Terminal events omit the duplicate raw providerEvent payload.
Per-turn backend options use the SDK's normalizeRuntimeBackendConfig for current model/auth fields and turn-scoped runtimeSecrets. Unknown options remain rejected, and native credentials still require the exact profile's subscription intent. Retained request identity excludes credential values, matching Sandbox message admission; Sandbox separately keys the executing backend by secret values so rotation cannot reuse stale authority.
Runtime MCP bindings travel through CreateAgentEnvironmentInput.runtimeAttachments to Sandbox's backend.runtimeAttachments.
They remain separate from the authored profile and its digest.
The adapter checks the selected backend catalog before provisioning and refuses missing attachment support.
Disabled attachments and aliases present in the profile are refused, including disabled profile aliases.
Per-turn backend overrides accept the same canonical attachment contract.
Remote managers still require an authenticated coordination URL reachable from the Sandbox.
Catalog support does not establish successful tool execution against a particular deployment.
The provider propagates catalog transport and validation errors unchanged, before provisioning.
A successfully read catalog without the selected backend reports its capabilities as unsupported.
Newer SDKs may also provide getBackend() as a lookup over the same catalog.
import { Sandbox } from "@tangle-network/sandbox";
import { createTangleProvider } from "@tangle-network/agent-provider-tangle";
const provider = createTangleProvider({
client: new Sandbox({
apiKey: process.env.TANGLE_API_KEY,
baseUrl: process.env.TANGLE_SANDBOX_URL || "https://sandbox.tangle.tools",
}),
});
Sandbox requires an explicit baseUrl.
Set TANGLE_SANDBOX_URL to use another deployment.
AgentTurnInput.profile carries the exact per-turn profile, including mounted file contents larger than the metadata limit.
The adapter projects it into Sandbox's backend profile and enforces the existing file, inline-payload, and aggregate limits.
Previously valid providerOptions.backend.profile inputs remain supported.
If both locations are supplied, their canonical profile digests must match; a conflict refuses dispatch before credential selection.
Use the typed field for large profiles; generic provider metadata keeps its existing limits.
Exact execution replay preserves the admitted request and its digest.
An explicit replay override cannot replace the selected account, credential channel, or harness.
Workspace file reads
environment.read accepts file contents up to 4 MiB, measured in UTF-8 bytes.
Runtime resolves child source files through this reader without copying their contents into tool arguments.
Paths keep the control-plane limit.
Live reads require the selected Sandbox workspace capability.
Evidence capture batches workspace reads into requests of at most 100 files and 8 MiB of declared file bytes.
A file larger than that batch target gets its own request.
Responses must account for every requested path exactly once; missing, duplicate, or unrelated paths fail capture.
Cancellation stops further batches after the current request returns.
Evidence capture retains full workspace files that exceed the Sandbox JSON read limit through bounded binary downloads.
This requires a Sandbox SDK whose filesystem advertises supportsBoundedDownload.
Capture verifies the downloaded size and SHA-256 receipt before adding bytes to the archive.
Aggregate limits, credential exclusions, and exact native-session proof remain enforced.
Capture lists no directory below a credential directory such as .opencode or .config: it records the directory as excluded and accounts for its subtree with one complete usage scan, so the workspace totals still reconcile.
An unsupported downloader or incomplete transfer fails capture and preserves the source environment.
Named model credentials at creation
modelCredentials sends a stored credential reference and explicit endpoint before Sandbox provisions the backend.
This uses Sandbox's existing caller-owned model credential path instead of requesting a managed Router credential.
The provider does not create, copy, or renew stored secrets.
The Provider grants the selected reference at create, alongside unrelated caller-requested secrets. Qualified native selectors also grant their selected reference before each fresh turn.
const provider = createTangleProvider({
client,
modelCredentials: {
apiKeyEnv: "RESEARCH_ROUTER_KEY",
baseUrl: "https://router.tangle.tools/v1",
},
});
const environment = await provider.create({
profile: researchProfile,
});
The configuration accepts only the reference and endpoint, not credential values or model selection.
The authored profile, selected backend, runtime attachments, and create identity remain unchanged.
The provider snapshots this configuration when constructed and refuses combining it with mapCreateInput.
Omitting it preserves managed creation.
The endpoint must use HTTP or HTTPS without credentials, query parameters, or fragments.
Credential-only turns retain the created SDK handle's endpoint and attachments.
Reconstructed handles leave omitted model settings to Sandbox's durable backend configuration.
Keep the stored secret available and valid throughout execution and recovery.
Per-profile subscription credentials
The exact profile chooses access through model.metadata.credentialSource.
Use "subscription" to request an enrolled account.
Omitting this field or using "managed" preserves managed access.
Unknown or malformed intent fails before provisioning.
profileCredentialSource(profile) exports this strict intent reader.
modelCredentials resolves only profiles that request subscription access.
Managed profiles skip the selector and configured native references.
Explicit "managed" intent also skips configured static API references.
Unmarked legacy profiles retain their existing static API configuration; mark mixed profiles explicitly or use a selector.
The same Provider can create a managed parent and subscription child, or a subscription parent and managed child.
A subscription profile requires a configured reference or successful selector result before Sandbox creation.
The selector receives a frozen copy of the exact create input, including its profile and idempotency key. Fresh-turn calls carry the exact profile, stable binding key, and public metadata.nativeCredentialTurn coordinates.
It returns one public native reference or the API reference described above.
It cannot change the profile, harness, model, reasoning controls, or runtime attachments.
const provider = createTangleProvider({
client,
modelCredentials: async (input) => accountOwner.resolve(input),
});
const environment = await provider.create({
profile: {
...researchProfile,
model: {
...researchProfile.model,
metadata: { ...researchProfile.model?.metadata, credentialSource: "subscription" },
},
},
idempotencyKey: persistedNodeIdentity,
});
A native result has this shape:
{
cliAuth: {
account: "research-account",
secretEnv: "RESEARCH_CLAUDE",
format: "token",
},
}
The Provider validates native channels with the maintained CLI Registry before provisioning.
Each selector must use the exact profile harness.
Native execution also requires the selected container to report cliAuthReferences: true.
A missing or unreadable capability refuses creation or dispatch before inference.
A selector grants its one selected stored secret automatically, alongside unrelated secrets requested by the caller.
Keep pool-wide account credentials out of the create input's secrets list.
Static native references grant their selected secret only for subscription profiles.
Static API references grant their selected secret for unmarked legacy profiles and subscription profiles.
Inline environment values cannot shadow the selected stored secret.
Undefined, malformed, unsupported, or failed subscription selections stop before Sandbox creation.
Subscription failures never fall back to managed Router access.
Sandbox metadata records the public selection under modelCredentials.
This metadata key is reserved for the Provider selection.
Account labels are supplied bindings, not verified native principals.
Token values and native credential files never enter that selection or receipt.
A static native reference stays bound to its creation account and harness on every turn.
Reconstructed environments recover that reference from persisted metadata and the observed backend.
A resolver selects a bootstrap credential at creation and a durable binding for each fresh turn.
Fresh turns require the exact inline subscription profile and a stable turnId.
Runtime supplies that profile through its maintained provider turn mapping; older Runtime callers can supply it through taskToTurn.
The binding key includes environment, session, and turn identity, so retrying the same operation retains its account after reconnect.
A deliberate new turn can select another eligible account. The selected reference enters the admitted request digest before dispatch.
The Provider grants only that named secret through the authenticated Sandbox API before native execution.
This handoff requires the SDK's grantNativeCredential operation and a container reporting claudeTokenContinuations: true.
Only Claude token references are qualified. A Claude bundle or files reference refuses dynamic handoff before inference.
Codex and Kimi references keep the account the resolver selected at creation on every turn; that admission already covered the run deadline.
The Sidecar retires the idle adapter, preserves its original transcript HOME and native session, and acquires the newly selected credential.
An active credential lease refuses replacement.
Exact event/result replay never selects or installs credentials and leaves the admitted request unchanged.
After a runtime restart, the next fresh turn grants its selected named reference before adapter acquisition.
The account owner must keep retry bindings and the original validity deadline durable; an unavailable grant never authorizes a paid fallback.
A turn carrying a profile must preserve its environment's credential source, including after recovery.
Changing credential sources or harnesses requires a separate environment.
A command owned by the account system
The Node subpath provides direct process transport for an existing account resolver.
It executes arguments without a shell and discards subprocess stderr.
import { createCommandModelCredentialResolver } from "@tangle-network/agent-provider-tangle/node";
const modelCredentials = createCommandModelCredentialResolver({
command: ["acct", "sandbox", "resolve", "--key-env", "TANGLE_SANDBOX_API_KEY"],
minimumValidUntil: persistedRunDeadline,
timeoutMs: 30_000,
});
const provider = createTangleProvider({ client, modelCredentials });
Managed profiles do not start the command or require an account binding.
For subscription profiles, the command receives JSON { input, minimumValidUntil? } on stdin.
input contains the exact create fields with signal omitted.
The command must return only { cliAuth } or { apiKeyEnv, baseUrl } on stdout.
It must bind selection durably to input.idempotencyKey; quota changes cannot switch an existing assignment during retry.
The adapter requires this idempotency key before starting the command.
minimumValidityMs can compute one fixed deadline at factory construction; recovery should use the persisted absolute deadline.
Only a successfully decoded public reference can reach Sandbox creation.
Timeouts, cancellation, nonzero exit, and malformed or oversized output refuse creation with sanitized errors.
A remote account owner
The Node subpath also provides createHttpModelCredentialResolver.
Use a narrow resolve-only grant and the owner's maintained HTTPS endpoint.
HTTP is accepted only on loopback for local forwarding.
The client never follows redirects or sends private account-owner credentials.
import { createHttpModelCredentialResolver } from "@tangle-network/agent-provider-tangle/node";
const modelCredentials = createHttpModelCredentialResolver({
url: process.env.ACCOUNT_RESOLVER_URL!,
bearer: process.env.ACCOUNT_RESOLVER_GRANT!,
minimumValidUntil: persistedRunDeadline,
});
const provider = createTangleProvider({ client, modelCredentials });
Managed profiles skip the remote call.
Subscription roots and descendants send the same exact { input, minimumValidUntil? } contract as the command transport.
The owner must bind the grant to a pool and fixed Sandbox namespace before accepting a request.
A caller-supplied namespace is never sufficient authorization.
The response contains only the selected stored reference.
Errors, redirects, invalid references, cancellation, and bounded response time refuse provisioning.
The client makes one request; recovery may retry the same immutable dispatch identity through the account owner's durable binding.
parseModelCredentialResolverRequest validates this public wire envelope without normalizing the authored profile.
Requests are bounded at 64 MiB and responses at 64 KiB.
The account owner retains authority over registration, namespace verification, holds, quota, selection, and trusted renewal.
Updating a stored secret does not refresh credentials already injected into a live Sandbox.
Creation-time validity and the selected host's native credential lifecycle still bound long-running work.
create() returns a ready environment
provider.create() does not return until the sandbox reports running.
This is the contract AgentEnvironmentProvider.create states, and every runtime seam relies on it: a caller streams the first turn immediately after create, with nothing in between.
A sandbox that never reaches running fails the create call with the platform's reason, and the adapter deletes the sandbox it could not hand over.
The gap this closes is narrow and specific.
client.create() waits by itself only when the create response reports pending or provisioning.
A response that already reports running skips that wait, and running alone is not usable: the SDK's own waitFor treats the target as reached only when filesystemIncarnationReadiness is ready, because the sandbox filesystem is still being built until then.
So create() can return a sandbox that reports running while the platform still holds a lifecycle operation on it, and the first turn lands on that lock.
Composing an environment also reads the sandbox's deployment capability document once, and a sandbox that is not yet running cannot answer that read, so an environment composed during provisioning would claim nothing for the rest of its life.
readyTimeoutMs is passed to the HTTP Sandbox.create request, including a cold image pull, and also bounds the subsequent wait for running. It defaults to DEFAULT_TANGLE_READY_TIMEOUT_MS (120 seconds). A client whose create method ignores request options keeps its own create timeout; the provider still uses readyTimeoutMs for its later wait.
const provider = createTangleProvider({
client: new Sandbox({
apiKey: process.env.TANGLE_API_KEY,
baseUrl: process.env.TANGLE_SANDBOX_URL || "https://sandbox.tangle.tools",
}),
readyTimeoutMs: 180_000,
});
const environment = await provider.create({
profile: { name: "worker", harness: "codex" },
});
for await (const event of environment.stream({ prompt: "run the task" })) {
}
The platform owns the wait, and this adapter runs no status loop of its own.
It calls waitFor("running") on the created instance when the linked SDK offers it, because that refreshes the created instance in place and keeps its create receipt.
It falls back to client.waitForRunning(id), then refreshes the created instance.
A client that offers neither cannot prove readiness, so the sandbox it returned has to report running by itself or the create call fails.
The adapter reads the platform's answer back rather than trusting the wait to have resolved for the right reason.
A create call returns only a sandbox that reports running: failed, stopped, and expired are refused with the status named, and so is a wait that resolves while the sandbox still reports provisioning.
The default create mapping selects create()'s backend, then the provider's defaultBackend, then the inline profile's harness.
An inline profile therefore carries its own backend unless the caller records an execution override.
Profiles without a harness retain the provider's legacy OpenCode fallback.
A configured mapCreateInput owns the Sandbox options and may select another backend or profile.
The created environment's capabilities use that mapping's backend; provider capabilities describe the configured default.
get(id) reads the running sandbox's backend status.
If that status is unavailable, the reconstructed environment advertises no backend-specific capabilities.
Per-turn backend options
AgentTurnInput.providerOptions.backend reaches PromptOptions.backend on the Sandbox prompt call.
This is the exact block agent-runtime emits for a per-turn backend or model override, so a turn can select its model, its inline profile, or a session credential bundle without any change to the environment.
await environment.stream({
prompt: "run the task",
providerOptions: {
backend: {
type: "opencode",
model: {
provider: "zai",
model: "glm-5.2",
authMode: "oauth",
authFiles: [{ path: ".config/opencode/auth.json", content: seatCredentials, mode: 0o600 }],
},
},
},
});
A field the Sandbox prompt options do not declare is refused, in providerOptions, in backend, and in backend.model.
The SDK drops what it does not declare, so a forwarded unknown field would run the turn on different settings with no error anywhere.
The accepted field sets are pinned to the SDK's BackendConfig at compile time, at all three levels including authFiles, so a field the SDK adds or removes fails this package's build instead of reaching a caller as a wrong refusal.
Values are checked, not just field names: an inline backend.profile is read by agentProfileSchema, the package that owns profile rules, and backend.metadata.traceAttributes is held to the entry and length limits the Sandbox platform states for it.
AgentTurnInput.interactions and backend.interactions state the same posture, and a disagreement between them is refused rather than resolved by preference.
A turn model that disagrees with backend.model.model is refused for the same reason.
AgentTurnInput.interactions still maps to backend.interactions for a turn that carries no backend block at all.
Backend options are part of the retained request digest, so a retry under the same turnId with a changed model, profile, or seat conflicts instead of replaying work that ran on other settings.
Bearer material is excluded from that identity: model.apiKey is dropped and authFiles are reduced to the paths and modes they install.
A rotated seat token is the same seat running the same work, so an ordinary refresh continues its run rather than conflicting with it.
Detached dispatch returns the immutable Sandbox execution receipt in controlRef.
The adapter validates its complete capability document and omits optional environment methods whose capabilities are disabled.
Created and reconstructed environments expose a recursively frozen Sandbox metadata snapshot for constant-time annotation checks.
Sandbox metadata can include caller-authored values and does not authenticate its author.
Reconstruct an exact session with environment.session(reference.id, { controlRef: reference.controlRef }); replay cursors are exclusive at both the agent interface and Sandbox session stream.
Result, replay, and cancel operations select that exact execution instead of whichever execution most recently changed the shared session.
Session status with an exact control reference reports a state only when the payload names that execution; a payload bound to a different or unnamed execution reports unknown.
Stream frames can contain complete tool output and cumulative assistant text.
The adapter bounds each complete serialized frame to 1 MiB of UTF-8, including keys and JSON escaping.
It retains accepted content without truncation in the event data and original provider event.
Identifiers, usage metadata, and structural JSON limits retain their existing bounds.
Usage before the terminal receipt
Sandbox reports an execution's usage on its terminal result and done frames, and the adapter marks that receipt usageMode: "cumulative".
Before the receipt, only the OpenCode harness forwards usage: its step_finish event arrives as a raw frame with that step's token counts.
The adapter attaches the execution's running total to each such frame as usage with usageMode: "cumulative".
The running total uses the receipt's convention: input includes cache reads and writes, and output includes reasoning.
A consumer can therefore enforce a token limit while the execution runs.
The adapter reports a running total only on a stream that starts at the execution's first frame.
That is environment.stream() without lastEventId, and an exact session.events() replay without since.
A cursor replay and the session's live tail miss earlier steps, so their step frames carry no usage.
A step that repeats under a reconnect is counted once, by its OpenCode part id.
After a step reports counts without both totals, later step frames carry no usage, because no later total is known.
A step with an invalid count fails the stream.
The terminal receipt remains authoritative, and the adapter never rewrites it or the step totals to agree.
In 21 recorded production OpenCode executions, the last step total equaled the receipt in every counter.
If a receipt is lower than the streamed total, the cumulative sequence falls, and a consumer that folds cumulative usage can report the conflict.
Sum usage across events only when usageMode is "delta".
Claude Code, Pi, and the other CLI backends forward no per-step usage, so their frames carry none.
Codex can forward its turn.completed record as a raw frame, but one Codex execution is one turn, so the adapter leaves that frame unchanged.
Two capability documents
Capabilities are derived in two stages, and the two stages answer different questions.
provider.capabilities() answers "what can this provider do against a deployment that backs it".
It runs before any sandbox exists, so it measures the adapter surface alone and states the adapter's ceiling for everything a deployment decides.
A lazy instance handle minted from the linked Sandbox SDK over the client's fetch transport must prove dispatchPrompt, session, and cancelRun, and the client must expose get for reconstruction; the probe sends no request and creates no resource.
That handle measures the linked SDK's method surface, never the connected service.
A client that cannot prove those facts gets no retained-control claim, so the runtime rejects retained dispatch before any sandbox is created.
environment.capabilities answers "what can this environment do", and it is the document to read before offering an operation.
Composing an environment calls box.capabilities() once and derives every deployment-decided claim from that document.
The operations an environment exposes match its own document exactly: a claim the document does not carry has no method behind it.
One provider reaches deployments of different ages, which is why the environment carries its own document rather than inheriting the provider's.
Each deployment flag this adapter reads gates the claims it backs, and no flag is read that gates nothing:
dispatch.runControlRef | streaming.detach, streaming.turnIdempotency, sessions.continue, retainedControl, session.cancelRun |
dispatch.executionIdOnAdmission | streaming.detach, streaming.turnIdempotency, sessions.continue, retainedControl, session.cancelRun |
cancel.canonicalRunCancellation | sessions.continue, retainedControl, session.cancelRun |
cancel.digestBound | sessions.continue, retainedControl, session.cancelRun |
cancel.idempotent | sessions.continue, retainedControl, session.cancelRun |
runs.eventReplay | streaming.replay, sessions.continue, retainedControl, session.cancelRun |
runs.executionScopedStatus | sessions.continue, retainedControl, session.cancelRun |
interactions.responseDedupe | interactions, environment.respondToInteraction, session.respondToInteraction |
interactiveAgent.* | interactiveAgent, exact control, and generation-fenced terminal input and resize |
Detached dispatch carries the caller's exact reference and refuses a receipt that does not name the execution back, so it needs both dispatch flags and a session handle to reach the run through.
sessions.continue, retainedControl, and session.cancelRun need every flag in the table, because the capability schema refuses a partial retained-control block and each identity rests on its own flag.
A claim takes its operation with it: streaming.detach gates dispatch(), and session() stands while any of streaming.detach, streaming.replay, or sessions.continue stands.
A missing flag means unknown, and unknown is never a claim.
Answering an ask rests on interactions.responseDedupe alone, and on nothing retained control needs.
The adapter keeps no record of the responses it sent, so every replay answer comes from the deployment: repeating a command returns the recorded acknowledgement, and a different answer for a recorded ask is refused as already_resolved_different.
A deployment that does not record what it acknowledges therefore claims no interactions, even where the local method exists, because an unrecorded response cannot be retried without risking a second answer to a running agent.
The claim and the two methods stand or fall together.
Answering an interaction
respondToInteraction takes the canonical InteractionResponseCommand and returns the canonical InteractionAcknowledgement.
It is offered on the environment and on a session handle; the environment routes the command to the session its binding names.
The command carries only the answer the caller supplied. No field is filled in on the caller's behalf, and an answer the outstanding ask's spec rejects is refused with the field named.
| The deployment recorded and delivered the response | accepted |
| The deployment already holds this exact response | already_resolved_same |
| The deployment already holds a different response for the ask | already_resolved_different, message naming the recorded digest |
| The command names another provider, environment, or session | binding_mismatch |
| The deployment refuses the binding | binding_mismatch |
| The ask is unknown to the deployment | unknown_interaction |
| The session is unknown to the deployment | unknown_run |
| The ask left the outstanding set on a deadline | expired |
| The ask's spec rejects the answer | invalid_response, message naming each field |
| The response is recorded and delivery is unconfirmed | transport_failure, retryable: true |
| The request did not reach the route | transport_failure, retryable: true |
A refusal is never reported as a success, and a recorded response whose delivery the deployment does not confirm is never reported as accepted.
Four inputs claim nothing at all: a Sandbox SDK older than 0.22.0, a sandbox that is not running, a null document (a deployment predating capability discovery, or one serving a newer schema this SDK cannot read), and a capability read that fails.
In each case the environment omits dispatch and session, so a caller never selects an action the deployment will reject.
A document that leaves a flag unset is not one of them.
It drops the claims that flag gates and keeps every claim its remaining flags back.
A document without cancel.digestBound still carries streaming.detach and streaming.replay, and its environment still exposes dispatch and session.
A failed read claims nothing rather than failing create(): discovery runs against a sandbox a cold provision has already paid for, and a transport failure is not evidence about the deployment.
The failure is reported on the warning channel.
The document is measured once, when the environment is composed.
A sandbox that is not yet running cannot answer, so an environment composed during provisioning claims nothing and keeps claiming nothing — the exposed operations and the document are composed together, and a caller may already hold either one.
Compose the environment again through provider.get(id) once the sandbox is running.
Pass the SDK client itself when retained control matters.
An object-spread wrapper ({ ...client }) drops class prototype methods, including fetch, so the provider treats the wrapper as a non-SDK client and claims no retained control.
A wrapper must delegate the SDK methods instead of copying properties.
After session.prompt() admits another turn, that session object's controlRef advances only when Sandbox returns the requested execution ID; a mismatched receipt fails without advancing local state.
Sandbox keeps execution identifiers optional for older or unproven service paths, so this adapter fails closed when a dispatch or prompt does not return one and never falls back to latest-session state.
Sessions reconstructed without a control reference may start a new prompt, but result lookup, cancellation, and cursor replay fail before calling Sandbox because those operations could otherwise select the newest unrelated execution.
It also rejects contextTransfer and nativeContinuation inputs explicitly until those operations have native Sandbox support instead of silently dropping them.
Workspace branching
environment.workspaceBranching is the single operation surface for creating
and recovering a checkpoint, forking one managed child, and cleaning both
resources in dependency order.
The adapter advertises branching.checkpoint, branching.fork, retrySafe,
lookup, and cleanup only when the linked SDK exposes the complete managed
surface: keyed snapshot, durable snapshot restore, inventory recovery, and
explicit deletion outcomes.
An incomplete SDK surface clears every branching flag and omits
environment.workspaceBranching.
Every operation validates the canonical agent-interface request digest before
calling Sandbox.
Retries with the same key replay the original resource, while changed material
returns a conflict containing the original interface digest.
The provider stores a bounded request marker in snapshot tags and child
metadata so a fresh process can recover the exact interface digest.
Snapshot-created children are validated through the marker and complete
account inventory, while legacy fork markers also require the Sandbox fork
ledger to report a settled success.
Checkpoint deletion reads only the source's own snapshots. A child created
from a checkpoint holds a full restore of it in its own volume, so deleting the
checkpoint neither waits for nor changes its children. One Sandbox path still
names the checkpoint after create: last-resort sidecar recreation replays the
child's original create request, and once the checkpoint is deleted that replay
fails with SNAPSHOT_NOT_FOUND (agent-container#9388).
The adapter never treats an SDK response without an explicit idempotency or
deletion outcome as success.
After a provider process restart, provider.workspaceBranching.forEnvironment()
returns a fresh source-scoped handle for lookup and cleanup, or null when it
cannot prove the complete operation surface.
Recovery reads the account inventory through Sandbox offset pages of at most
1,000 sandboxes and continues until a short page proves the inventory is
complete.
If a live snapshot loses its marker tags, recovery reads the owner-scoped
operation record by key and binds its stored result to that live snapshot id.
The operation record alone never revives a deleted snapshot.
An older Sandbox deployment that cannot answer the owner-scoped lookup returns
unknown and does not create or mutate a checkpoint.
The adapter also reads checkpoints from the previous marker format while writing only the current bounded format.
Malformed, repeated, failed, or over-bound pages return unknown and do not
mutate a resource.
Sandbox exposes snapshots only through the live source instance.
If that source was deleted, this provider cannot recover its checkpoints or
forked children through the public SDK.
Clean branch resources before deleting the source or use a platform reaper.
import {
workspaceCheckpointRequestDigest,
workspaceForkRequestDigest,
} from "@tangle-network/agent-interface";
const checkpoint = await environment.workspaceBranching?.checkpoint({
source: exactRun,
idempotencyKey: "checkpoint-before-analysis",
requestDigest: workspaceCheckpointRequestDigest({ source: exactRun }),
});
if (checkpoint?.status === "created" || checkpoint?.status === "replayed") {
const fork = await environment.workspaceBranching?.fork({
checkpoint: checkpoint.checkpoint,
placement: { kind: "sandbox", sandboxId: "analysis-worker" },
idempotencyKey: "analysis-worker",
requestDigest: workspaceForkRequestDigest({
checkpoint: checkpoint.checkpoint,
placement: { kind: "sandbox", sandboxId: "analysis-worker" },
}),
});
}
Confidential forks
Sandbox returns raw TEE evidence, not a verified claim.
The deployed Tangle job does not carry snapshot restore or sealed-persistence inputs.
The provider therefore refuses new confidential workspace forks before creation.
Existing confidential fork records still require a matching TEE report and an external verifier before they become trusted.
Pass confidentialAttestationVerifier to createTangleProvider to connect a
trusted provider-key and measurement verifier.
The callback receives the raw report, the expected environment binding, and
the canonical request-bound attestation material.
It returns a provider key id, signature, and optional normalized measurement
only after verification succeeds.
Returning null, throwing, a mismatched measurement, or a copied quote leaves
the result unverified while preserving confidentialRequested: true.
The same unverified result occurs when no trusted verifier is configured.
The adapter does not trust the requested nonce, child metadata, or any legacy
confidential: true assertion as proof.
verifyTangleQuote below represents a verifier supplied by the caller.
const provider = createTangleProvider({
client: new Sandbox({
apiKey: process.env.TANGLE_API_KEY,
baseUrl: process.env.TANGLE_SANDBOX_URL || "https://sandbox.tangle.tools",
}),
confidentialAttestationVerifier: async ({ report, attestation }) =>
(await verifyTangleQuote({ report, attestation })) ?? null,
});
The provider stores the raw report in ConfidentialAttestation.quote through
encodeTangleConfidentialAttestationQuote.
The quote is a versioned canonical JSON object with base64url byte fields.
Use decodeTangleConfidentialAttestationQuote to recover a report and reject
legacy numeric-array quotes, unknown fields, malformed bytes, and oversized
reports before verification.
Environment observation
environment.observe() returns the normalized AgentEnvironmentObservation.
Every surface carries a freshness discriminator, so a value the Sandbox SDK does not report is visibly absent instead of arriving as a measured zero.
The observation capability flag for a surface is true only when a source can put a value on it for that environment; the observation itself always carries the surface, with unavailable and its reason when there is nothing to report.
identity | provider name and box.id | always known |
lifecycle.status | box.status after a refresh | stale with the refresh failure |
lifecycle.cleanup | box.expiresAt as a scheduled retirement | omitted from the lifecycle value |
endpoint | scheme, host, and explicit port of connection.runtimeUrl | unavailable |
placement.verified | client.describePlacement(box) | unavailable |
resources.requested | the create() resource request | omitted on an environment rebuilt by id |
resources.effective | cgroup memoryLimitMb and the attached GPU lease | unavailable |
resourceUse.current / peak | cgroup memoryCurrentMb / memoryPeakMb | unavailable |
modelUsage | the newest execution this handle measured | unavailable |
computeBilling | the GPU lease's billed or estimated customer cost | unavailable |
accountUsage | client.subscription() and client.usage() | unavailable |
Four quantities have no Sandbox source at all, and the adapter reports them as absent rather than deriving them.
There is no effective CPU or disk figure anywhere in the SDK, so resources.effective carries memory and the accelerator only.
The cgroup reports cumulative CPU microseconds, which is not a utilization figure, so resourceUse carries memory only rather than a rate computed from one sample.
Sandbox prices an attached GPU lease and publishes no per-sandbox container compute cost, so computeBilling is unavailable on an environment with no lease.
lifecycle.continuity and lifecycle.persistence have no readable source and are omitted.
Two account values are reported as absent on purpose.
A negative credit balance under an overage plan cannot be stated as non-negative remaining credit, and an account with no concurrent-sandbox ceiling has no quota to state; reporting either as zero would hide a debt or invent a limit.
resources.requested is the request the adapter sends, not the request the caller typed.
The contract states CPU, memory, and disk as cpu/memoryMb/diskMb while Sandbox reads cpuCores/memoryMB/diskGB, so the adapter translates them; a disk size that is not a whole number of gibibytes is refused rather than rounded.
A resources.gpu class becomes one accelerator device, which is the Sandbox default device count for a request that names only its class.
The observation makes two calls of its own — the account subscription and usage counters — plus the cgroup sample and a refresh.
A hop that fails does not fail the observation: its surface carries the transport's own message as the reason, and a value the refresh could not renew is reported as stale rather than as current.
Nothing in the observation can carry a credential.
The endpoint holds only a scheme, a host, and an explicit port; userinfo, path, and query are dropped, and the bearer beside the runtime URL is never read.
Interactive terminal
environment.attachTerminal(request) opens or reattaches one PTY over the sandbox terminal WebSocket, and environment.terminal(id) returns the live handle.
The four interactiveTerminal flags rest on one fact — the sandbox serves the PTY socket and reports terminal metadata — because all four operations reach the caller through one socket from one linked SDK.
Attach uses the request's connectionId, else its terminalSessionId, else a generated id; reusing an id reattaches the PTY and replays its retained screen, which the runtime reports as reattached.
mode: "logical" is refused: Sandbox resumes a terminal only by attaching it, and serving an attach the caller did not ask for would start a PTY behind their back.
events() replays retained frames from an EXCLUSIVE since cursor and then continues until the terminal exits.
Only output frames carry a sequence; ready, resize, error, and exit keep their place in the same ordered log.
A cursor the retained buffer no longer holds is refused, because resuming after the gap would drop output the consumer believes it received.
Every reference states an expiry one detach window past the newest activity the adapter can prove, and input and resize refuse a reference that is expired or no longer running.
attachCount counts the attaches this environment holds, not the runtime's own viewers.
close() reports closed only when the socket delivered an exit; Sandbox exposes no terminal delete, so an unproven close reports unknown rather than claiming a termination that did not happen.
An attach whose runtime metadata lacks a name, shell, working directory, geometry, timestamps, running state, or detach window fails closed with unknown, because a reference cannot state facts the runtime did not report.
The exact interactive adapter validates control before attach, prompt, and stop operations.
An attached terminal sends input and resize operations through its authenticated terminal connection.
The sidecar authorizes those mutations, so the provider does not add a second REST request before each operation.
Terminal close only detaches the caller's socket, so it stays available after control moves to a newer coordinator.
Pass exactProcess: {} only when the Sandbox deployment supports agent: false creates and reports metadata.runtimeMode: "control".
The optional capability creates an ephemeral sandbox with an authenticated control service but no managed agent workload or agent credentials, explicit resources, exact blocked/domain egress, bounded binary file reads, shell-free launch, and recoverable process output plus terminal reason.
Set teamId inside exactProcess to scope create, lookup, and recovery to one team.
const provider = createTangleProvider({
client: new Sandbox({
apiKey: process.env.TANGLE_API_KEY,
baseUrl: process.env.TANGLE_SANDBOX_URL || "https://sandbox.tangle.tools",
}),
exactProcess: {},
});
const environment = await provider.exactProcess!.create({
image: "ghcr.io/acme/agent@sha256:<64-hex-manifest-digest>",
egress: { mode: "blocked" },
maxLifetimeMs: 120_000,
resources: { cpu: 1, memoryMb: 1024, diskMb: 1024 },
metadata: { executionId: "run-1" },
idempotencyKey: "run-1",
});
The adapter rejects ordinary sandboxes during create, recovery, and list operations.
Private session evidence
Set requireNativeSessionCapture: true when the provider must retain complete native evidence.
Sandbox selects a host with fresh capture proof and returns proof for the created container.
The provider matches that proof to the create receipt before returning the environment.
Agent creation and reconnect check the selected container's capture protocol and exact backend through box.capabilities().
Unsupported agent capture refuses before a model turn.
A newly created container is cleaned up when its required proof is missing.
An unrelated host or pending fleet update does not determine this container's admission.
Exact-process creation retains its container proof and existing managed-process output and terminal contracts.
Checkpoint restoration requires its container proof and exact source/checkpoint identity.
A restored agent environment verifies its actual backend when reconnected.
The capture requirement also reaches checkpoint operations exposed by an environment.
Fleet capability summaries are diagnostic observations, not placement admission.
For large captures, import captureTangleEnvironmentEvidenceToDirectory or captureTangleSandboxEvidenceToDirectory from @tangle-network/agent-provider-tangle/node.
Pass destination alongside the same capture options.
The destination must not exist; its parent must exist.
The result contains directory and provenance; callers own removal after durable persistence.
Failed captures remove their private destination and native-export staging.
Directory capture syncs each staged file by default. A caller that keeps the source environment until the captured bytes have been verified and stored durably may pass syncStagedFiles: false to avoid a sync for each temporary file. The option does not change byte, digest, mode, or completeness checks; it does not make the staging tree durable across a host failure.
Directory capture streams workspace files, native payloads, and event records into private files.
It requires Sandbox session.exportRawEvidence() and its dedicated server archive route.
It never falls back to the aggregate native JSON response.
Native metadata is limited to 32 MiB and 100,000 records; an individual event record is limited to 16 MiB.
Exceeding a bound refuses capture instead of reporting complete evidence.
Budget disk for the captured tree, archive persistence, and overlapping native export.
Use Runtime streamed workspace capture and restore to preserve bounded memory through persistence.
The directory preserves the existing retention manifest, exact payload bytes, modes, attribution, and completeness rules.
Call captureTangleEnvironmentEvidence(environment, options) before destroying a provider environment.
For a Runtime-owned box, call captureTangleSandboxEvidence(box, options) with exact session and execution IDs.
Set workspaceRoot to a canonical relative worker directory when the box is shared.
Both functions return private binary files and provenance for the workspace, native session roots, process streams, and transport replay.
The caller must store those bytes in private durable custody before deleting the box.
Credential exclusions remain listed with path, type, owner IDs, timestamps, mode, size, and reason.
Each process stream has exact frame order and a terminal receipt.
The attempt inventory binds each Sidecar execution and retry to provider sessions, observed native sessions, and recorded processes.
Each session has one raw-manifest.json in its private archive.
Each execution binds to its original HOME through evidenceSources.
Native files and process streams use that source ID in their archive paths.
Rotations keep earlier HOMEs until durable capture permits session deletion.
Every readable process spool keeps its exact original bytes in processSources.
It preserves all response metadata, including unknown fields, attempts, event buffers, terminal results, exclusions, and skipped paths.
Known contentBase64 fields become JSON pointers in contentRefs.
Each pointer names the archive file whose verified bytes reconstruct that field.
Binary bytes are stored once.
An unavailable response also keeps its original metadata.
Sidecar execution IDs join sessions[].executionIds; the outer execution ID names the Runtime node.
Partial capture retains the readable native files, process frames, and events in the same archive.
Its missing reasons prevent a complete receipt and keep the source available for recovery.
An unknown attempt outcome or native session identity remains explicit.
Reconnecting uses the execution ID from the validated durable control reference.
Earlier callers are recovered from retained server execution.started records only when the current exact control reference matches its recorded admission.
Recovery validates the provider, environment, session and execution, and preserves each original reference and request digest in sessions[].controlRefs.
Missing, malformed or conflicting historical admissions stay in the raw manifest with explicit gaps.
The attempt projection includes only caller-known or admission-verified execution IDs.
The capture records the running sidecar image digest and the served Sidecar bundle revision.
The image digest can be compared with the pre-create proof only when that proof identifies the actual placement image.
An incomplete inventory, missing stream, unrelated backend, or mismatched digest does not produce complete evidence.
Exact retained workspace capture
Pass the admitted controlRef to captureTangleEnvironmentEvidence.
Keep executionId as the Runtime artifact identity.
The control reference supplies the exact native environment, session, and execution identifiers.
Capture validates those coordinates before reading files.
A reconstructed environment does not need the original in-memory session cache.
The returned provenance retains both identities and reports incomplete native coverage explicitly.
Missing native session evidence preserves the source under Runtime's complete-provenance policy.
The remote request is bounded to 8 MiB; public replies are bounded to 64 KiB.
A genuine HTTP 429 or 503 may return { error: { code, reason, resetAt? } }.
Supported codes are provider_quota_exhausted for exhausted quota and upstream_unavailable for unknown, held, or busy capacity.
The adapter exposes these as TangleCredentialCapacityError, preserving the actual HTTP status and known reset timestamp.
Authentication failures and malformed metadata do not become typed quota refusals.
The command transport accepts the same public capacity body only with exit code 75.
Its typed error has no HTTP status.
The caller's execution owner decides whether and when to retry.
Completion and native evidence
The provider projects terminal results and terminal stream events into bounded completion summaries.
Cumulative toolInvocations stay in native session evidence; they are not copied into the summary or truncated.
A projected terminal event omits providerEvent, which would otherwise claim to contain the full native event.
The session, execution, and event identities still bind the summary to its source.
Use captureTangleEnvironmentEvidence or the exact Sandbox session's rawEvidence() to retain original arguments, outputs, and process records.
Capture completeness and missing-source reasons remain authoritative; a successful completion does not imply complete archival capture.
Individual event, terminal text, identity, and usage validation remain unchanged.