{"slug": "introducing-sandcastle", "title": "Introducing Sandcastle", "summary": "Matt Pocock introduced Sandcastle, an AI agent orchestration tool that uses Git worktrees to run multiple AI agents in parallel without version control conflicts. The tool, available on GitHub and installable via npm, requires a Claude Code OAuth token and a GitHub personal access token to manage issues and pull requests. Sandcastle is positioned as a low-level primitive for developers to build custom multi-agent workflows.", "body_md": "This is a post about [Sandcastle](https://github.com/mattpocock/sandcastle), an AI Agent Orchestration tool. What is AI orchestration? The short answer is that it’s the process of getting multiple agents to work on things at the same time. If you’re wondering why you need a tool for that, well, read on.\n\nWhy do I need a tool to run multiple agents?\n\nLet’s say you have 4 issues that can be implemented simultaneously. You might wonder why you can’t just spin up 4 different terminals, start Claude in each, write your prompts, and hit enter. That would work, sort of. But think about your version control system. At the end of the day, git works off your file system. Any changes you make to files are immediately reflected as unstaged. Yes, any or all of those agents can create branches, but you can only have one branch checked out at any given time. Basically, those agents will all be stepping on each other.\n\nThat said, there’s a git feature that predates AI by a generation and is designed to solve this very problem: workstrees. Worktrees have existed in git for over 10 years, but I’ll be honest, I’d never even heard of them before AI. Back when we were writing our code manually, life was simple: check out a branch, do work, commit, and push. Then potentially switch to a different branch. Rinse and repeat. You have one working directory, which has one branch open at any given time.\n\nWhat are Git Worktrees?\n\nWorktrees allow you to have multiple working directories of your repo checked out on disk at any given time. Each of these separate working directories can have its own branch checked out.\n\nThis is obviously a fantastic solution for orchestrating AI agents to work on things in parallel.\n\nBut if you think this is the part of the post where I get into the git commands necessary to use Worktrees, things have already evolved past that. There are tools that happily and elegantly manage these things for you, and this is a post about one of them: [Sandcastle](https://github.com/mattpocock/sandcastle).\n\nSetting Expectations\n\nIf you’re imagining a high-level tool that will magically do amazing things for you out of the box, please understand that this is a low-level primitive. It performs some low-level tasks extremely well, allowing you to put together your own workflow for your own project as needed.\n\nAnd with that, let’s get started.\n\nInstallation\n\nInstall it into your project.\n\n```\nnpm install --save-dev @ai-hero/sandcastle\n```\n\nThen run this to kick off the setup:\n\n```\nnpx @ai-hero/sandcastle init\n```\n\nIf you let it, it’ll set up the default Docker image. This is the image Sandcastle will use to run your agents (if you select the Docker sandbox) to ensure isolation from each other.\n\nLooking Around\n\nLet’s see what was created for us. You should see a `.sandcastle`\n\nfolder, inside of which there should be a `.env.example`\n\nfile. Mine looks like this.\n\n```\n# Claude Code OAuth token — get one by running `claude setup-token` on your host.\n# Lets the agent use your Claude subscription instead of an API key.\nCLAUDE_CODE_OAUTH_TOKEN=\n# Or use an Anthropic API key instead — uncomment and fill in:\n# ANTHROPIC_API_KEY=\n# GitHub personal access token — the agent uses it to read and manage GitHub Issues\n# Create a fine-grained token: https://github.com/settings/personal-access-tokens/new\n# Required repository permissions: Issues (Read and write) and Metadata (Read)\nGH_TOKEN=\n```\n\nRename it to `.env`\n\n, and then let’s get it filled out. It needs a Claude Code token, and a GitHub token; the latter is needed for things like creating GitHub issues, and pull requests.\n\nThe instructions for the Claude token are self-explanatory and listed right there: just run `claude setup-token`\n\nin a terminal (*not* a Claude session).\n\nFor the GitHub token, head to the [new Personal Access Token](https://github.com/settings/personal-access-tokens/new) page.\n\nGive your token a name, expiration, etc. And for permissions, make **sure** you select what’s below, at a minimum.\n\nMake sure contents, pull requests, and issues all have read/write permissions.\n\nHello World\n\nThe simplest possible way to run Sandcastle is via the sample `main.ts`\n\nfile that was scaffolded. It looks like this by default.\n\n``` js\nimport { run, claudeCode } from \"@ai-hero/sandcastle\";\nimport { docker } from \"@ai-hero/sandcastle/sandboxes/docker\";\n\n// Blank template: customize this to build your own orchestration.\n// Run this with: npx tsx .sandcastle/main.ts\n// Or add to package.json scripts: \"sandcastle\": \"npx tsx .sandcastle/main.ts\"\n\nawait run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: docker(),\n  promptFile: \"./.sandcastle/prompt.md\",\n});\n```\n\nHere’s the `prompt.md`\n\nfile that was generated\n\n```\n# Context\n\n<!-- Use !`command` to pull in dynamic context. Commands run inside the sandbox. -->\n<!-- Example: !`git log --oneline -10` or !`gh issue list --state open --label Sandcastle --limit 100 --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` -->\n\n# Task\n\n<!-- Describe what the agent should do. -->\n\n# Done\n\n<!-- When the task is complete, output <promise>COMPLETE</promise> to signal early termination. -->\n```\n\nTo get things working, I’ll put this into the `Task`\n\nsection\n\n```\nAdd a new `add.ts` file that exports a single function called `add` that takes two numbers, and returns their sum.\n```\n\nAnd now I can run this command:\n\n```\nnpx tsx .sandcastle/main\n```\n\nWhich will hopefully log something like this:\n\n``` php\n ---->npx tsx .sandcastle/main\n[Agent] Started on branch temp\n  tail -f .sandcastle/logs/temp.log\n```\n\nAlong with a new `add.ts`\n\nfile, and something like this inside:\n\n```\nexport function add(a: number, b: number): number {\n  return a + b;\n}\n```\n\nBuilding Something Useful\n\nBeing able to type a prompt into a Markdown file and run it with a `tsx`\n\ncommand is useless on its own; you can just type the prompt directly into Claude Code (or whatever harness you like).\n\nBut being able to run a prompt with a single line of TypeScript is an incredibly valuable primitive we can build cool workflows on. If we can run a single prompt with a single function call, then we can easily run multiple prompts in parallel, and with Sandcastle handling worktree creation, we won’t have to worry about file conflicts. In fact, using the Docker Sandbox option will provide even further isolation, beyond just git working directories.\n\nLet’s build a script that sniffs out all open GitHub issues, lets the user choose which ones to execute, and, for the chosen ones, spins off parallel agents.\n\nGrabbing Our GitHub Issues\n\nGetting the GitHub issues is easy; there’s a CLI for that.\n\n```\ngh issue list \\\n  --state open \\\n  --limit 100 \\\n  --json number,title,body,labels,blockedBy\n```\n\nThe command above will produce something like this:\n\n```\n[\n  {\n    \"blockedBy\": {\n      \"nodes\": [\n        {\n          \"id\": \"I_kwDOTwhgQM8AAAABL7tDgA\",\n          \"number\": 7,\n          \"state\": \"OPEN\",\n          \"title\": \"Two-column board UI for tickets\",\n          \"url\": \"https://github.com/arackaf/render-atl-ai-sandbox/issues/7\"\n        }\n      ],\n      \"totalCount\": 1\n    },\n    \"body\": \"## What to build\\n\\nWire up drag-and-drop on the ticket board so users can move cards between the \\\"To Do\\\" and \\\"Done\\\" columns to update a ticket's status.\\n\\n- Install `@dnd-kit/core` (no `@dnd-kit/sortable` — no within-column reordering)\\n- Cards are draggable between columns\\n- Dropping a card in a new column calls a new `updateTicketStatus` server function that accepts `{ id, status }` (narrow contract)\\n- Optimistic update via TanStack Query mutation — card moves immediately on drop\\n- On success: invalidate the tickets query (background refetch)\\n- On failure: silent revert (no error UI for now)\\n\\n## Acceptance criteria\\n\\n- [ ] `@dnd-kit/core` is installed\\n- [ ] Cards can be dragged between \\\"To Do\\\" and \\\"Done\\\" columns\\n- [ ] Drop triggers `updateTicketStatus` server function with `{ id, status }`\\n- [ ] `updateTicketStatus` updates the issue's status in the database\\n- [ ] UI updates optimistically on drop\\n- [ ] Tickets query is invalidated on successful mutation\\n- [ ] Failed mutations silently revert the card to its original column\\n\\n## Blocked by\\n\\n- #7 — Two-column board UI for tickets\",\n    \"labels\": [\n      {\n        \"id\": \"LA_kwDOTwhgQM8AAAACvX0ewQ\",\n        \"name\": \"ready-for-agent\",\n        \"description\": \"Fully specified, ready for an AFK agent\",\n        \"color\": \"0E8A16\"\n      }\n    ],\n    \"number\": 8,\n    \"title\": \"Drag-and-drop status updates\"\n  },\n  {\n    \"blockedBy\": {\n      \"nodes\": [\n        {\n          \"id\": \"I_kwDOTwhgQM8AAAABL7tBhg\",\n          \"number\": 6,\n          \"state\": \"OPEN\",\n          \"title\": \"Server functions and loader for tickets and epics\",\n          \"url\": \"https://github.com/arackaf/render-atl-ai-sandbox/issues/6\"\n        }\n      ],\n      \"totalCount\": 1\n    },\n    \"body\": \"## What to build\\n\\nThe index page renders a two-column Kanban-style board with columns for \\\"To Do\\\" and \\\"Done\\\". Ticket data comes from the route loader.\\n\\n- Two side-by-side columns, each with a header (\\\"To Do\\\" / \\\"Done\\\")\\n- Tickets are split by their `status` field\\n- Each card shows only the ticket title\\n- No epic badges, no grouping, no special mobile layout\\n- Styled with Tailwind CSS\\n\\n## Acceptance criteria\\n\\n- [ ] Index page displays two side-by-side columns labelled \\\"To Do\\\" and \\\"Done\\\"\\n- [ ] Tickets are split into the correct column based on `status`\\n- [ ] Each card displays only the ticket title\\n- [ ] Styled with Tailwind utility classes\\n\\n## Blocked by\\n\\n- #6 — Server functions and loader for tickets and epics\",\n    \"labels\": [\n      {\n        \"id\": \"LA_kwDOTwhgQM8AAAACvX0ewQ\",\n        \"name\": \"ready-for-agent\",\n        \"description\": \"Fully specified, ready for an AFK agent\",\n        \"color\": \"0E8A16\"\n      }\n    ],\n    \"number\": 7,\n    \"title\": \"Two-column board UI for tickets\"\n  }\n  // and so on\n]\n```\n\nWriting Our Script\n\nAI wrote this script for me, but I’ll show you the highlights and then the full script at the end. This is just what I thought would be useful; you can put these primitives together however you’d like.\n\nWe can execute that GitHub CLI command from Node.\n\n``` js\nconst output = execFileSync(\"gh\", [\"issue\", \"list\", \"--state\", \"open\", \"--limit\", \"100\", \"--json\", \"number,title,body,blockedBy\"], {\n  encoding: \"utf8\",\n});\n```\n\nWe probably want to filter for issues that are *not* blocked by other open issues.\n\n``` js\nconst availableIssues = issues.filter(issue => !issue.blockedBy?.nodes?.some(blocker => blocker.state === \"OPEN\"));\n```\n\nTo build a decent UI in our terminal, we can use [the @inquirer/prompts library](https://www.npmjs.com/package/@inquirer/prompts).\n\n``` js\nimport { checkbox } from \"@inquirer/prompts\";\n```\n\nThe library has a nice CLI prompt UI, so we can write something like this:\n\n``` js\nconst selectedIssueIds = await checkbox({\n  message: \"Select issues to implement:\",\n  choices: availableIssues.map(issue => ({\n    name: issue.title,\n    value: issue.number,\n  })),\n});\n```\n\nThen we can fire off our agents using the same `run`\n\nmethod we saw before.\n\n``` js\nPromise.all(\n  selectedIssueIds.map(async issueId => {\n    run({\n      agent: claudeCode(\"claude-opus-4-6\"),\n      sandbox: docker(),\n      prompt: `Implement gh issue ${issueId}. Commit your changes and push to origin. Open a PR.`,\n      branchStrategy: {\n        type: \"branch\",\n        branch: `agent/gh-issue-${issueId}`,\n        baseBranch: \"main\",\n      },\n      logging: {\n        type: \"stdout\",\n        verbose: false,\n      },\n    })\n      .then(resp => `${sep}\\n\\nIssue ${issueId} completed:\\n\\n${resp}\\n\\n${sep}\\n\\n`)\n      .catch(error => `${sep}\\n\\nIssue ${issueId} failed: ${error}\\n\\n${sep}\\n\\n`);\n  }),\n).then(() => {\n  console.log(\"All issues completed\");\n});\n```\n\nBut with some additional instructions on branching and creating pull requests.\n\nWhen we run this script, it looks like this.\n\nWe can select tickets.\n\nThen fire it off.\n\nWhen it’s done, we should see pull requests created.\n\nThe Whole Script\n\nHere’s the entire script. Remember, this should be (at most) your starting point, for crafting a workflow tailored to your own needs.\n\n``` js\nimport { run, claudeCode } from \"@ai-hero/sandcastle\";\nimport { docker } from \"@ai-hero/sandcastle/sandboxes/docker\";\n\nimport { execFileSync } from \"node:child_process\";\nimport { checkbox } from \"@inquirer/prompts\";\n\ntype Issue = {\n  number: number;\n  title: string;\n  body: string;\n  blockedBy: {\n    nodes: {\n      number: number;\n      title: string;\n      state: string;\n    }[];\n  };\n};\n\nconst output = execFileSync(\"gh\", [\"issue\", \"list\", \"--state\", \"open\", \"--limit\", \"100\", \"--json\", \"number,title,body,blockedBy\"], {\n  encoding: \"utf8\",\n});\n\nconst issues: Issue[] = JSON.parse(output);\n\nconst availableIssues = issues.filter(issue => !issue.blockedBy?.nodes?.some(blocker => blocker.state === \"OPEN\"));\n\nconst selectedIssueIds = await checkbox({\n  message: \"Select issues to implement:\",\n  choices: availableIssues.map(issue => ({\n    name: issue.title,\n    value: issue.number,\n  })),\n});\n\nconst sep = \"------------------------------------\";\n\nPromise.all(\n  selectedIssueIds.map(async issueId => {\n    run({\n      agent: claudeCode(\"claude-opus-4-6\"),\n      sandbox: docker(),\n      prompt: `Implement gh issue ${issueId}. Commit your changes and push to origin. Open a PR.`,\n      branchStrategy: {\n        type: \"branch\",\n        branch: `agent/gh-issue-${issueId}`,\n        baseBranch: \"main\",\n      },\n      logging: {\n        type: \"stdout\",\n        verbose: false,\n      },\n    })\n      .then(resp => `${sep}\\n\\nIssue ${issueId} completed:\\n\\n${resp}\\n\\n${sep}\\n\\n`)\n      .catch(error => `${sep}\\n\\nIssue ${issueId} failed: ${error}\\n\\n${sep}\\n\\n`);\n  }),\n).then(() => {\n  console.log(\"All issues completed\");\n});\n```\n\nWrapping Up\n\nSandcastle is a wonderful library for crafting agentic workflows. It provides you with incredibly useful primitives you can combine however you need, based on your own workflow.", "url": "https://wpnews.pro/news/introducing-sandcastle", "canonical_source": "https://blog.master.dev/introducing-sandcastle/", "published_at": "2026-08-24 12:49:54+00:00", "updated_at": "2026-08-24 13:17:53.828115+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["Matt Pocock", "Sandcastle", "GitHub", "Claude Code", "Anthropic", "npm"], "alternates": {"html": "https://wpnews.pro/news/introducing-sandcastle", "markdown": "https://wpnews.pro/news/introducing-sandcastle.md", "text": "https://wpnews.pro/news/introducing-sandcastle.txt", "jsonld": "https://wpnews.pro/news/introducing-sandcastle.jsonld"}}