diff --git a/.github/pr-assets/code-display/after-jobs-1440-light.png b/.github/pr-assets/code-display/after-jobs-1440-light.png new file mode 100644 index 00000000..5661a400 Binary files /dev/null and b/.github/pr-assets/code-display/after-jobs-1440-light.png differ diff --git a/.github/pr-assets/code-display/after-jobs-390-dark.png b/.github/pr-assets/code-display/after-jobs-390-dark.png new file mode 100644 index 00000000..edeb09fd Binary files /dev/null and b/.github/pr-assets/code-display/after-jobs-390-dark.png differ diff --git a/.github/pr-assets/code-display/before-jobs-1440-light.png b/.github/pr-assets/code-display/before-jobs-1440-light.png new file mode 100644 index 00000000..5bff4abd Binary files /dev/null and b/.github/pr-assets/code-display/before-jobs-1440-light.png differ diff --git a/.github/pr-assets/code-display/before-jobs-390-dark.png b/.github/pr-assets/code-display/before-jobs-390-dark.png new file mode 100644 index 00000000..da60ed3b Binary files /dev/null and b/.github/pr-assets/code-display/before-jobs-390-dark.png differ diff --git a/.github/pr-assets/code-display/copy-demo-4x.mp4 b/.github/pr-assets/code-display/copy-demo-4x.mp4 new file mode 100644 index 00000000..79e81458 Binary files /dev/null and b/.github/pr-assets/code-display/copy-demo-4x.mp4 differ diff --git a/components/CodeDisplay.tsx b/components/CodeDisplay.tsx new file mode 100644 index 00000000..b56b8954 --- /dev/null +++ b/components/CodeDisplay.tsx @@ -0,0 +1,165 @@ +import { getShikiHighlighter, SHIKI_SUPPORTED_LANGUAGES } from "./shiki"; +import { useTheme } from "nextra-theme-docs"; +import { CheckIcon, ClipboardIcon } from "lucide-react"; +import React, { useEffect, useRef, useState } from "react"; + +type Language = (typeof SHIKI_SUPPORTED_LANGUAGES)[number]; + +interface CodeDisplayProps { + code: string; + language: Language; + title?: string; + fromLine?: number; + sourceUrl?: string; +} + +export default function CodeDisplay({ + code, + language, + title = language === "typescript" ? "TypeScript" : language, + fromLine = 1, + sourceUrl, +}: CodeDisplayProps) { + const { theme, systemTheme } = useTheme(); + const highlightTheme = + (theme === "system" ? systemTheme : theme) === "dark" + ? "github-dark" + : "github-light"; + const [highlight, setHighlight] = useState<{ + code: string; + language: Language; + theme: string; + html: string; + } | null>(null); + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">( + "idle", + ); + const copyRequest = useRef(0); + const currentCode = useRef(code); + currentCode.current = code; + + useEffect(() => { + let active = true; + getShikiHighlighter() + .then((highlighter) => { + const html = highlighter.codeToHtml(code, { + lang: language, + theme: highlightTheme, + }); + if (active) + setHighlight({ code, language, theme: highlightTheme, html }); + }) + .catch(() => { + // The readable source remains available if highlighting cannot load. + }); + return () => { + active = false; + }; + }, [code, language, highlightTheme]); + + useEffect(() => { + copyRequest.current += 1; + setCopyState("idle"); + return () => { + copyRequest.current += 1; + }; + }, [code]); + + useEffect(() => { + if (copyState !== "copied") return; + const timeout = setTimeout(() => setCopyState("idle"), 2000); + return () => clearTimeout(timeout); + }, [copyState]); + + const copy = async () => { + const request = ++copyRequest.current; + try { + await navigator.clipboard.writeText(code); + if (request === copyRequest.current && currentCode.current === code) + setCopyState("copied"); + } catch { + if (request === copyRequest.current && currentCode.current === code) + setCopyState("failed"); + } + }; + + const currentHighlight = + highlight?.code === code && + highlight.language === language && + highlight.theme === highlightTheme + ? highlight.html + : null; + const startLineStyle = { "--start-line": fromLine } as React.CSSProperties; + const lines = code.split("\n"); + + return ( +
+
+ + {title} + +
+ {sourceUrl && ( + + View on GitHub + + )} + +
+
+ + {copyState === "failed" + ? "Could not copy. Select the code and copy it manually." + : copyState === "copied" + ? "Code copied." + : ""} + +
+ {currentHighlight ? ( +
+ ) : ( +
+            
+              {lines.map((line, index) => (
+                
+                  {line}
+                  {index < lines.length - 1 ? "\n" : null}
+                
+              ))}
+            
+          
+ )} +
+
+ ); +} diff --git a/components/GithubFileReaderDisplay.tsx b/components/GithubFileReaderDisplay.tsx index ad951260..a45094e0 100644 --- a/components/GithubFileReaderDisplay.tsx +++ b/components/GithubFileReaderDisplay.tsx @@ -1,11 +1,7 @@ -import { - dedentCode, - getLanguage, - getShikiHighlighter, -} from "@/components/shiki"; -import { useTheme } from "nextra-theme-docs"; -import React, { useEffect, useMemo, useState } from "react"; -import { FaGithub, FaLink, FaSpinner } from "react-icons/fa"; +import { dedentCode, getLanguage } from "./shiki"; +import CodeDisplay from "./CodeDisplay"; +import React, { useEffect, useState } from "react"; +import { FaSpinner } from "react-icons/fa"; interface GithubFileReaderDisplayProps { url: string; @@ -15,130 +11,96 @@ interface GithubFileReaderDisplayProps { dedent?: boolean; } -const GithubFileReaderDisplay: React.FC = ({ +export default function GithubFileReaderDisplay({ url, fromLine = 1, toLine, title, dedent = true, -}) => { - const [content, setContent] = useState(""); - const [loading, setLoading] = useState(true); +}: GithubFileReaderDisplayProps) { + const [file, setFile] = useState<{ url: string; text: string } | null>(null); const [error, setError] = useState(null); - const { theme, systemTheme } = useTheme(); - - const currentTheme = useMemo(() => { - if (theme === "system") { - return systemTheme; - } - - return theme; - }, [systemTheme, theme]); useEffect(() => { - const fetchAndHighlightContent = async () => { + const controller = new AbortController(); + setFile(null); + setError(null); + const fetchContent = async () => { try { const rawUrl = url .replace("github.com", "raw.githubusercontent.com") .replace("/blob/", "/"); - - const [response, highlighter] = await Promise.all([ - fetch(rawUrl), - getShikiHighlighter(), - ]); - - if (!response.ok) { - throw new Error("Failed to fetch file content"); - } - + const response = await fetch(rawUrl, { signal: controller.signal }); + if (!response.ok) throw new Error("Failed to fetch file content"); const text = await response.text(); - const lines = text.split("\n"); - const selectedLines = lines.slice(fromLine - 1, toLine || lines.length); - let codeContent = selectedLines.join("\n"); - - // Apply dedentation if enabled - if (dedent) { - codeContent = dedentCode(codeContent); - } - - // Set the theme based on current theme - const theme = currentTheme === "dark" ? "github-dark" : "github-light"; - - // Highlight the code with the current theme and line numbers - const highlightedCode = highlighter.codeToHtml(codeContent, { - lang: getLanguage(url), - theme: theme, - }); - - // Wrap the highlighted code with a div that sets the starting line number - const wrappedCode = `
${highlightedCode}
`; - setContent(wrappedCode); - setLoading(false); - } catch (err) { - console.error("Highlighting error:", err); - setError(err instanceof Error ? err.message : "An error occurred"); - setLoading(false); + if (!controller.signal.aborted) setFile({ url, text }); + } catch (error) { + if (!controller.signal.aborted) + setError( + error instanceof Error + ? error.message + : "Failed to fetch file content", + ); } }; + void fetchContent(); + return () => controller.abort(); + }, [url]); - fetchAndHighlightContent(); - }, [url, fromLine, toLine, currentTheme, dedent]); - - if (loading) { + if (error) { return ( -
- +
+ {error}
); } - if (error) { + if (!file || file.url !== url) { return ( -
- Error: {error} +
+
); } - const getGithubLineLink = () => { - // Add line numbers to GitHub URL - const lineFragment = toLine ? `#L${fromLine}-L${toLine}` : `#L${fromLine}`; - return `${url}${lineFragment}`; - }; - - return ( -
-
-
- - - {title || url.split("/").slice(-1)[0]} - -
-
- - Lines {fromLine}-{toLine || "end"} - - - - -
+ const lines = file.text.split("\n"); + if ( + !Number.isInteger(fromLine) || + fromLine < 1 || + fromLine > lines.length || + (toLine !== undefined && + (!Number.isInteger(toLine) || toLine < fromLine || toLine > lines.length)) + ) { + return ( +
+ The source line range is unavailable.
+ ); + } + const selected = lines.slice(fromLine - 1, toLine ?? lines.length).join("\n"); + const code = dedent ? dedentCode(selected) : selected; + const fragment = toLine ? `#L${fromLine}-L${toLine}` : `#L${fromLine}`; -
-
-
-
+ return ( + ); -}; - -export default GithubFileReaderDisplay; +} diff --git a/components/shiki.tsx b/components/shiki.tsx index f3deda81..e1f8f7e3 100644 --- a/components/shiki.tsx +++ b/components/shiki.tsx @@ -7,6 +7,7 @@ import { export const SHIKI_SUPPORTED_LANGUAGES = [ "rust", + "bash", "typescript", "javascript", "solidity", diff --git a/lib/sandbox-code.ts b/lib/sandbox-code.ts new file mode 100644 index 00000000..2b0c56d7 --- /dev/null +++ b/lib/sandbox-code.ts @@ -0,0 +1,285 @@ +export const sandboxCode = { + installSdk: `npm install @tangle-network/sandbox +npm install --save-dev tsx`, + exportApiKey: `read -rsp "Tangle API key: " TANGLE_API_KEY +printf "\\n" +export TANGLE_API_KEY`, + checkBalance: `curl -fsS https://id.tangle.tools/v1/billing/balance \\ + -H "Authorization: Bearer \${TANGLE_API_KEY:?Set TANGLE_API_KEY}"`, + checkServices: `curl -fsS https://sandbox.tangle.tools/health +curl -fsS https://sandbox.tangle.tools/v1/public-templates`, + createSandbox: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const box = await client.create({ environment: "universal", name: "agent-smoke" }); + +try { + const result = await box.exec("node --version && npm --version"); + console.log(result.stdout); +} finally { + await box.delete(); +}`, + runAgent: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const box = await client.create({ + environment: "universal", + backend: { type: "opencode" }, // Also supports Claude Code, Codex, and other supported harnesses. +}); + +try { + const result = await box.prompt("List the files in this project and summarize what it does"); + console.log(result); +} finally { + await box.delete(); +}`, + installSdkReference: `npm install @tangle-network/sandbox +npm install --save-dev tsx`, + client: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", + timeoutMs: 30000, +});`, + create: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const box = await client.create({ + name: "my-project", + environment: "universal", + backend: { type: "opencode" }, // Also supports Claude Code, Codex, and other supported harnesses. + env: { NODE_ENV: "development" }, + resources: { cpuCores: 2, memoryMB: 4096, diskGB: 20 }, + maxLifetimeSeconds: 3600, + idleTimeoutSeconds: 900, +}); + +try { + console.log(box.id); +} finally { + await box.delete(); +}`, + listGetUsage: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const sandboxId = process.env.TANGLE_SANDBOX_ID; +if (!sandboxId) throw new Error("Set TANGLE_SANDBOX_ID to an existing sandbox ID"); + +const running = await client.list({ status: "running", limit: 10 }); +const box = await client.get(sandboxId); +if (!box) throw new Error("Sandbox not found"); +const usage = await client.usage(); +console.log(running, box.id, usage);`, + runBatch: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const jobId = process.env.TANGLE_BATCH_JOB_ID; +if (!jobId) throw new Error("Set TANGLE_BATCH_JOB_ID to your saved job ID"); + +const result = await client.runBatch( + { + tasks: [ + { id: "task-1", message: "Create a JavaScript function that adds two numbers and test it." }, + { id: "task-2", message: "Create a JavaScript function that reverses a string and test it." }, + ], + backends: [{ id: "worker", type: "opencode" }], + }, + { idempotencyKey: jobId }, +); +console.log(result.totalSuccess, result.totalFailure);`, + exec: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const box = await client.create({ environment: "universal" }); +try { + const result = await box.exec("node --version", { + cwd: "/workspace", + env: { CI: "true" }, + timeoutMs: 60000, + }); + console.log(result.exitCode, result.stdout); +} finally { + await box.delete(); +}`, + prompt: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const box = await client.create({ environment: "universal" }); +try { + const result = await box.prompt("Create a JavaScript function that adds two numbers."); + console.log(result); + + for await (const event of box.streamPrompt("Add tests for that function and run them.")) { + console.log(event); + } +} finally { + await box.delete(); +}`, + taskSessions: `import { randomUUID } from "node:crypto"; +import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const repoUrl = process.env.TANGLE_REPO_URL; +if (!repoUrl) throw new Error("Set TANGLE_REPO_URL to a public Git repository URL"); + +const box = await client.create({ + environment: "universal", + git: { url: repoUrl }, +}); +try { + const { session: task } = await box.createTaskSession({ + sessionId: randomUUID(), + title: "Write a README", + backend: { type: "opencode" }, // Also supports Claude Code, Codex, and other supported harnesses. + isolateFileWrites: true, + }); + await task.sendMessage({ + parts: [{ type: "text", text: "Write a README explaining this workspace." }], + turnId: randomUUID(), + }); + console.log(await task.result()); + console.log(await task.changes()); +} finally { + await box.delete(); +}`, + durableSessions: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const sandboxId = process.env.TANGLE_SANDBOX_ID; +const sessionId = process.env.TANGLE_SESSION_ID; +const turnId = process.env.TANGLE_TURN_ID; +if (!sandboxId || !sessionId || !turnId) { + throw new Error("Set TANGLE_SANDBOX_ID, TANGLE_SESSION_ID, and TANGLE_TURN_ID from your saved job record"); +} + +const box = await client.get(sandboxId); +if (!box) throw new Error("Sandbox not found"); +const receipt = await box.dispatchPrompt("Analyze code quality", { + sessionId, + turnId, +}); +console.log(receipt); + +const cached = await box.findCompletedTurn(turnId, { + sessionId: receipt.sessionId, +}); +if (cached) { + console.log(cached.result); +} else { + if (!receipt.executionId) throw new Error("Missing execution ID"); + const final = await box.session(receipt.sessionId).result({ + executionId: receipt.executionId, + }); + console.log(final); +}`, + gpuLeases: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const box = await client.create({ environment: "universal" }); +try { + const lease = await box.gpu.attach({ + accelerator: { kind: "nvidia-h100", count: 1 }, + maxSpendUsd: 5, + maxLifetimeSeconds: 600, + }); + try { + console.log(await box.gpu.exec(lease.id, { command: "nvidia-smi" })); + } finally { + await box.gpu.detach(lease.id); + } +} finally { + await box.delete(); +}`, + lifecycle: `import { Sandbox } from "@tangle-network/sandbox"; + +const apiKey = process.env.TANGLE_API_KEY; +if (!apiKey) throw new Error("Set TANGLE_API_KEY"); + +const client = new Sandbox({ + apiKey, + baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", +}); + +const box = await client.create({ environment: "universal" }); +try { + await box.stop(); + await box.resume(); + console.log((await box.exec("node --version")).stdout); +} finally { + await box.delete(); +}`, +} as const; diff --git a/pages/developers/blueprint-runner/jobs.mdx b/pages/developers/blueprint-runner/jobs.mdx index ad8a551e..2f1cf258 100644 --- a/pages/developers/blueprint-runner/jobs.mdx +++ b/pages/developers/blueprint-runner/jobs.mdx @@ -29,20 +29,20 @@ Jobs in a Blueprint are defined in the library package of your project. A job de ### Basic Job Structure -Here's an example of a simple job definition from the Incredible Squaring example: +This source excerpt defines `square` in the Incredible Squaring example. +Run it as part of that example project. In this example: -- `XSQUARE_JOB_ID` is a constant that uniquely identifies the job +- The router registers `square` under the `XSQUARE_JOB_ID` constant - `square` is the function that implements the job's logic -- The job takes a single ABI-encoded input parameter `x` extracted by `TangleArg` +- The job takes a single ABI-encoded input parameter `x` extracted by `TangleArg<(u64,)>` - The job returns a `TangleResult`, which the runner ABI-encodes for submission ## Job Context @@ -54,23 +54,18 @@ Jobs can access context information provided by the Blueprint Runner. This conte - State information - Utility functions -Context is typically passed to jobs through the router configuration: - -```rust -let router = Router::new() - .route(MY_JOB_ID, my_job) - .with_context(my_context); -``` +Pass shared state with `Router::with_context`. +Jobs read that state through the `Context` extractor. +See the [Contexts guide](/developers/contexts/introduction) for complete examples. ## Job Registration Jobs need to be registered to a route with the [router](/developers/blueprint-runner/routers) to be accessible. This is done when defining a Blueprint Runner: ## Job Execution Flow diff --git a/pages/sandbox/quickstart.mdx b/pages/sandbox/quickstart.mdx index 8dbdf185..2bec964d 100644 --- a/pages/sandbox/quickstart.mdx +++ b/pages/sandbox/quickstart.mdx @@ -3,26 +3,28 @@ title: Quickstart description: Create a sandbox and run your first coding agent in a few minutes. --- +import CodeDisplay from '/components/CodeDisplay'; +import { sandboxCode } from '/lib/sandbox-code'; + # Quickstart Create an isolated machine, run a shell command in it, then hand it to a coding agent, all from a few lines of TypeScript. ## 1. Install the SDK -```bash -npm install @tangle-network/sandbox -npm install --save-dev tsx -``` + ## 2. Get an API key Create an `sk-tan-` key from [Authentication](/platform/authentication) and export it: -```bash -read -rsp "Tangle API key: " TANGLE_API_KEY -printf "\n" -export TANGLE_API_KEY -``` + One key authenticates every Tangle product. Pass it to the client as `apiKey`; the SDK does not read env vars for you. @@ -35,18 +37,18 @@ On the Free plan, sandboxes pause when the balance reaches zero. Check your key and available credit without provisioning a machine: -```bash -curl -fsS https://id.tangle.tools/v1/billing/balance \ - -H "Authorization: Bearer ${TANGLE_API_KEY:?Set TANGLE_API_KEY}" -``` + The response's `data.balance` is your available credit in US dollars. You can also check the service and available templates without creating a sandbox: -```bash -curl -fsS https://sandbox.tangle.tools/health -curl -fsS https://sandbox.tangle.tools/v1/public-templates -``` + Each TypeScript block below is a separate program for Node.js 22 or later. Run it in a project with the SDK installed and `TANGLE_API_KEY` exported. @@ -55,26 +57,10 @@ Both examples release their machine after completion. ## 4. Create a sandbox and run a command -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const box = await client.create({ environment: "universal", name: "agent-smoke" }); - -try { - const result = await box.exec("node --version && npm --version"); - console.log(result.stdout); -} finally { - await box.delete(); -} -``` + `create` returns a live machine; `delete` releases it. You never provision infrastructure yourself. @@ -83,29 +69,10 @@ try { Choose the initial coding harness with `backend.type`. Sessions can select different [supported harnesses](/infrastructure/harnesses) in the same sandbox. `opencode` (OpenCode) is the default and needs no separate model-provider key. `claude-code` (Claude Code) runs on Tangle Router by default with no separate model-provider key. To bring your own Anthropic credential, pass `backend: { type: 'claude-code', model: { apiKey: process.env.ANTHROPIC_API_KEY } }`. See [supported harnesses](/infrastructure/harnesses) for the full list. -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const box = await client.create({ - environment: "universal", - backend: { type: "opencode" }, // Also supports Claude Code, Codex, and other supported harnesses. -}); - -try { - const result = await box.prompt("List the files in this project and summarize what it does"); - console.log(result); -} finally { - await box.delete(); -} -``` + For durable runs, use `box.dispatchPrompt(message, { sessionId, turnId })` and reconnect with `box.session(sessionId)`. Store both IDs before dispatching and reuse them for retries of the same turn. diff --git a/pages/sandbox/sdk-reference.mdx b/pages/sandbox/sdk-reference.mdx index 956bcaf0..a6273c97 100644 --- a/pages/sandbox/sdk-reference.mdx +++ b/pages/sandbox/sdk-reference.mdx @@ -3,6 +3,9 @@ title: SDK reference description: The core @tangle-network/sandbox surface, creating sandboxes, running commands and agents, durable sessions, and GPU leases. --- +import CodeDisplay from '/components/CodeDisplay'; +import { sandboxCode } from '/lib/sandbox-code'; + # SDK reference The core surface of `@tangle-network/sandbox`. The [npm package](https://www.npmjs.com/package/@tangle-network/sandbox) documents every option; this page covers what most builders reach for. @@ -12,57 +15,26 @@ Examples that create machines or run agents consume account credit. Creation examples release their machines in `finally`. Save one block as `example.ts` and run it with `npx tsx example.ts`. -```bash -npm install @tangle-network/sandbox -npm install --save-dev tsx -``` + ## Client -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", - timeoutMs: 30000, -}); -``` + ### `client.create(options?)` Create a sandbox and get back a `SandboxInstance`. Common options: -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const box = await client.create({ - name: "my-project", - environment: "universal", - backend: { type: "opencode" }, // Also supports Claude Code, Codex, and other supported harnesses. - env: { NODE_ENV: "development" }, - resources: { cpuCores: 2, memoryMB: 4096, diskGB: 20 }, - maxLifetimeSeconds: 3600, - idleTimeoutSeconds: 900, -}); - -try { - console.log(box.id); -} finally { - await box.delete(); -} -``` + `environment` accepts a named environment from `client.environments.list()`, a container image reference, or an SDK-built image ID. Omit it to use the server default. @@ -75,57 +47,19 @@ To restore a snapshot, provide both `fromSnapshot` and its owning `fromSandboxId Set `TANGLE_SANDBOX_ID` to an existing sandbox you own. This example reads that machine and your account usage without creating a machine. -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const sandboxId = process.env.TANGLE_SANDBOX_ID; -if (!sandboxId) throw new Error("Set TANGLE_SANDBOX_ID to an existing sandbox ID"); - -const running = await client.list({ status: "running", limit: 10 }); -const box = await client.get(sandboxId); -if (!box) throw new Error("Sandbox not found"); -const usage = await client.usage(); -console.log(running, box.id, usage); -``` + ### `client.runBatch(request, options?)` Run one-shot tasks across freshly provisioned sandboxes in parallel. For coordinated multi-machine work with shared workspaces and policy caps, use [fleets](https://www.npmjs.com/package/@tangle-network/sandbox) instead. -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const jobId = process.env.TANGLE_BATCH_JOB_ID; -if (!jobId) throw new Error("Set TANGLE_BATCH_JOB_ID to your saved job ID"); - -const result = await client.runBatch( - { - tasks: [ - { id: "task-1", message: "Create a JavaScript function that adds two numbers and test it." }, - { id: "task-2", message: "Create a JavaScript function that reverses a string and test it." }, - ], - backends: [{ id: "worker", type: "opencode" }], - }, - { idempotencyKey: jobId }, -); -console.log(result.totalSuccess, result.totalFailure); -``` + Select batch workers from the [supported harnesses](/infrastructure/harnesses). Reuse an `idempotencyKey` only when retrying the same batch with the same request body. @@ -140,57 +74,19 @@ Without an idempotency key, cancellation or disconnection also stops server work Run a shell command. -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const box = await client.create({ environment: "universal" }); -try { - const result = await box.exec("node --version", { - cwd: "/workspace", - env: { CI: "true" }, - timeoutMs: 60000, - }); - console.log(result.exitCode, result.stdout); -} finally { - await box.delete(); -} -``` + ### `box.prompt(message, options?)` · `box.streamPrompt(message, options?)` Run one agent turn. `prompt` returns the result; `streamPrompt` yields events as they happen. -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const box = await client.create({ environment: "universal" }); -try { - const result = await box.prompt("Create a JavaScript function that adds two numbers."); - console.log(result); - - for await (const event of box.streamPrompt("Add tests for that function and run them.")) { - console.log(event); - } -} finally { - await box.delete(); -} -``` + ### `box.createTaskSession(options)` · `box.taskSession(id)` @@ -198,42 +94,10 @@ Create a background task session with isolated file changes using one of the [su Set `TANGLE_REPO_URL` to a public Git repository URL. The example clones that repository before creating the task session. -```typescript -import { randomUUID } from "node:crypto"; -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const repoUrl = process.env.TANGLE_REPO_URL; -if (!repoUrl) throw new Error("Set TANGLE_REPO_URL to a public Git repository URL"); - -const box = await client.create({ - environment: "universal", - git: { url: repoUrl }, -}); -try { - const { session: task } = await box.createTaskSession({ - sessionId: randomUUID(), - title: "Write a README", - backend: { type: "opencode" }, // Also supports Claude Code, Codex, and other supported harnesses. - isolateFileWrites: true, - }); - await task.sendMessage({ - parts: [{ type: "text", text: "Write a README explaining this workspace." }], - turnId: randomUUID(), - }); - console.log(await task.result()); - console.log(await task.changes()); -} finally { - await box.delete(); -} -``` + This example clones your repository, inspects isolated changes, then deletes its machine. For retained tasks, store the sandbox ID and task session ID instead of deleting the machine. @@ -251,45 +115,10 @@ Set `TANGLE_SANDBOX_ID`, `TANGLE_SESSION_ID`, and `TANGLE_TURN_ID` from that sav This example retains the existing sandbox so another process can reconnect. Delete it when the job and any retries finish. -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const sandboxId = process.env.TANGLE_SANDBOX_ID; -const sessionId = process.env.TANGLE_SESSION_ID; -const turnId = process.env.TANGLE_TURN_ID; -if (!sandboxId || !sessionId || !turnId) { - throw new Error("Set TANGLE_SANDBOX_ID, TANGLE_SESSION_ID, and TANGLE_TURN_ID from your saved job record"); -} - -const box = await client.get(sandboxId); -if (!box) throw new Error("Sandbox not found"); -const receipt = await box.dispatchPrompt("Analyze code quality", { - sessionId, - turnId, -}); -console.log(receipt); - -const cached = await box.findCompletedTurn(turnId, { - sessionId: receipt.sessionId, -}); -if (cached) { - console.log(cached.result); -} else { - if (!receipt.executionId) throw new Error("Missing execution ID"); - const final = await box.session(receipt.sessionId).result({ - executionId: receipt.executionId, - }); - console.log(final); -} -``` + Store the sandbox ID and dispatch receipt to reconnect from another process. Use `client.get(sandboxId)` to obtain the sandbox, then select the receipt's session and execution IDs. @@ -306,56 +135,17 @@ The completed-turn cache can outlive its session; use `findCompletedTurn` to ret Keep the base sandbox cheap and attach a GPU only around the step that needs it. Every lease takes a hard spend cap and lifetime. -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const box = await client.create({ environment: "universal" }); -try { - const lease = await box.gpu.attach({ - accelerator: { kind: "nvidia-h100", count: 1 }, - maxSpendUsd: 5, - maxLifetimeSeconds: 600, - }); - try { - console.log(await box.gpu.exec(lease.id, { command: "nvidia-smi" })); - } finally { - await box.gpu.detach(lease.id); - } -} finally { - await box.delete(); -} -``` + ## Lifecycle -```typescript -import { Sandbox } from "@tangle-network/sandbox"; - -const apiKey = process.env.TANGLE_API_KEY; -if (!apiKey) throw new Error("Set TANGLE_API_KEY"); - -const client = new Sandbox({ - apiKey, - baseUrl: process.env.SANDBOX_BASE_URL ?? "https://sandbox.tangle.tools", -}); - -const box = await client.create({ environment: "universal" }); -try { - await box.stop(); - await box.resume(); - console.log((await box.exec("node --version")).stdout); -} finally { - await box.delete(); -} -``` + ## Next