Herdr plugin: run a coding agent in an Upstash Box from the worktree you are looking at
TypeScript
3
28 commits
updated Sep 10, 2026
Run a coding agent in an Upstash Box from the worktree you are looking at in Herdr.
Focus a pane inside a Git worktree, invoke Start agent in Upstash Box, and the plugin creates a box, uploads a filtered copy of the worktree, opens a new pane, and puts the agent in it. Close the pane and the agent keeps running. Come back with Reconnect, and a box that idled and paused in the meantime resumes on its own. When the agent has done something, Apply changes brings its edits back as a checked Git patch.
The pane runs the real Claude Code, Codex, or OpenCode terminal UI, kept alive under tmux inside the box. Your provider credential is passed into each session and never handed to the box.
herdr plugin install upstash/herdr-upstash-box
The install step fetches the Box SDK and CLI and compiles the plugin, so nothing needs to be on your PATH.
To work on it locally instead:
git clone https://github.com/upstash/herdr-upstash-box
cd herdr-upstash-box
npm install
npm run build
herdr plugin link "$(pwd)"
herdr plugin action invoke setup --plugin upstash.box
Setup opens a popup that asks for your Upstash Box API key and checks it against the API before anything else, then asks which agent and which provider credential to use, and writes config.json and secrets.json (mode 600) for you. Keys are typed without echo, so nothing lands in the scrollback. If you pick the subscription option and Claude Code is installed on this machine, setup runs claude setup-token for you: your browser opens to approve it, and the token is captured from the command's output without ever being shown. Otherwise you paste one from another terminal, and a line break inside the paste is fine. Either way the token is checked with Anthropic before it is saved. A credential that is already present can be kept or replaced, unless it comes from the environment: the environment outranks the file, so setup says to unset it there instead.
Everything setup writes can also be written by hand, as below.
Keys can come from the environment Herdr runs in, or from a secrets.json in the plugin config directory. The environment is enough when you start Herdr from a terminal; the file exists because Herdr launched from the Dock or Spotlight does not inherit your shell exports.
herdr plugin config-dir upstash.box
{
"UPSTASH_BOX_API_KEY": "...",
"CLAUDE_CODE_OAUTH_TOKEN": "...",
"ANTHROPIC_API_KEY": "...",
"OPENROUTER_API_KEY": "..."
}
CLAUDE_CODE_OAUTH_TOKEN is a subscription token from claude setup-token, so a Claude Pro or Max plan can drive Claude Code in the box without a pay-per-token Console key. For Claude Code on an anthropic/ model the plugin looks for CLAUDE_CODE_OAUTH_TOKEN first and ANTHROPIC_API_KEY second. Set providerApiKeyEnv to name one variable outright; then only that variable is read, and a missing value is an error rather than a fallback to some other key, so a session can never bill an account you did not choose. Whichever credential is in force, the competing Claude variables are blanked on the exec session so nothing inherited from the image can win.
Environment variables win over the file. The file must be a regular file that you own; if other users can read it, the plugin tightens it to mode 600 before reading.
Create config.json in the same directory. Every key is optional.
{
"harness": "claude-code",
"model": "anthropic/claude-sonnet-5",
"agentArgs": [],
"runtime": "node",
"size": "small",
"keepAlive": false,
"boxNamePrefix": "herdr",
"remoteRoot": "/workspace/home/worktree",
"providerApiKeyEnv": null,
"allowMultipleBoxes": false,
"excludedPaths": [],
"allowSensitivePaths": [],
"maxFiles": 10000,
"maxFileBytes": 10485760,
"maxUploadBytes": 104857600,
"maxPatchBytes": 52428800,
"previewPorts": [3000, 5173, 8000],
"previewAuth": "basic"
}
| Setting | Default | Purpose |
|---|---|---|
harness | claude-code | claude-code, codex, or opencode. Already installed in every box image. |
model | anthropic/claude-sonnet-5 | A Box model id with its provider prefix. Claude Code takes anthropic/ and openrouter/ models. Codex needs an openai/ model. OpenCode takes any. Incompatible pairs fail at config time. |
agentArgs | [] | Extra arguments appended to the harness command. ["--dangerously-skip-permissions"] lets Claude Code edit without approving each change, which is reasonable in a disposable box with a scoped key and wrong on a laptop; it is never the default for an interactive start. |
runtime | node | Box runtime image. |
size | small | small, medium, or large. |
keepAlive | false | Keep the box running instead of letting it pause when idle. |
boxNamePrefix | herdr | Prefix for generated box names. |
remoteRoot | /workspace/home/worktree | Where the worktree lands in the box. Must stay under /workspace. A subdirectory keeps the agent's own config directories out of the Git baseline. |
providerApiKeyEnv | null | Name the one variable that carries the provider credential. By default the plugin tries the variables that fit the harness and model, subscription token first for Claude Code. Set this and nothing else is ever substituted. |
allowMultipleBoxes | false | Allow more than one live box per worktree. Off by default, so a second Start points you at the existing box. |
excludedPaths | [] | Extra repository-relative paths left out of the upload. |
allowSensitivePaths | [] | Exact files the safety filter would otherwise exclude. |
maxFiles | 10000 | Upload file count limit. |
maxFileBytes | 10485760 | Per-file upload limit, 10 MiB. |
maxUploadBytes | 104857600 | Total upload limit, 100 MiB. |
maxPatchBytes | 52428800 | Largest patch Apply will download from the box, 50 MiB. |
previewPorts | [3000, 5173, 8000] | Ports the previews pane may expose with a public URL. |
previewAuth | basic | basic puts basic auth on every new public URL. none makes the link reachable by anyone who has it. |
Unknown keys and invalid values stop the action with a named error.
herdr plugin action invoke setup --plugin upstash.box
herdr plugin action invoke start-agent --plugin upstash.box
herdr plugin action invoke start-claude --plugin upstash.box
herdr plugin action invoke start-codex --plugin upstash.box
herdr plugin action invoke start-opencode --plugin upstash.box
herdr plugin action invoke reconnect --plugin upstash.box
herdr plugin action invoke apply-changes --plugin upstash.box
herdr plugin action invoke info --plugin upstash.box
herdr plugin action invoke stop --plugin upstash.box
herdr plugin action invoke delete-box --plugin upstash.box
herdr plugin action invoke pause --plugin upstash.box
herdr plugin action invoke resume --plugin upstash.box
herdr plugin action invoke snapshot --plugin upstash.box
herdr plugin action invoke fork --plugin upstash.box
herdr plugin action invoke previews --plugin upstash.box
herdr plugin action invoke dashboard --plugin upstash.box
A binding for Start:
[[keys.command]]
key = "prefix+shift+u"
command = "herdr plugin action invoke start-agent --plugin upstash.box"
Start claude, Start codex, and Start opencode are Start on a named harness for one launch, meant for key bindings: config.json is not touched, the configured model is kept when that harness can use it and otherwise the harness default applies, and agentArgs and providerApiKeyEnv are dropped because they were written for the configured harness. Herdr cannot pass arguments to an action, which is why these are three verbs rather than one flag. They are still one box per worktree: a second Start on a worktree that already has a live box is refused whichever verb you use. On reconnect, a providerApiKeyEnv written for the configured harness and model is ignored for a mapping it does not fit, whether a different harness or a different provider: a box created on an openrouter/ model keeps its OpenRouter key even after setup moved the config to a subscription token, because the model is recorded per box.
Start checks the worktree, refuses if a box already exists for it, then splits the focused pane, creates a box named after the worktree, uploads the filtered tree, records a Git baseline in the box, and opens the agent. Reconnect attaches again from any pane the mapping knows, resuming a paused box first and finishing any preparation a crash interrupted. If the pane Start was invoked from no longer exists, which is the case after any Herdr restart, Reconnect anchors to the focused pane instead and remembers it. Apply changes exports what changed in the box since the last apply as a binary Git patch, checks it against the worktree, shows the summary, and applies it after you say yes. Stop ends the agent session and keeps the box. Delete asks you to type DELETE in a popup, then removes the box and its mapping. Info shows the box status, agent session, paths, and export markers.
Pause ends the agent session and pauses the box; Resume brings it back without opening the agent. Snapshot saves the box state under a timestamped name. Fork asks you to type FORK, snapshots the box, and starts a second box from that snapshot for the same worktree, with its own mapping, so two directions can run from the same point. Previews exposes one of the configured ports with a public URL, with basic auth by default, and removes it again. Dashboard opens a zoomed board of every box the plugin owns.
Herdr shows one popup at a time. If a verb reports that another popup is already open, close that popup and run it again. A popup left unattended closes itself after two minutes so it cannot block the other verbs.
The board lists every mapping newest first, with worktree and branch, box name, agent, local lifecycle, live remote status, and age, and refreshes the remote column every few seconds. Boxes that carry the plugin label but have no mapping appear as orphans and can only be deleted. Every verb is one key away and opens the same pane the matching action would:
[j/k] Select [enter/r] Reconnect [a] Apply [i] Info [s] Stop [p] Pause [u] Resume
[n] Snapshot [f] Fork [v] Previews
[d] Delete [R] Refresh [q] Close
A binding for it:
[[keys.command]]
key = "prefix+shift+b"
command = "herdr plugin action invoke dashboard --plugin upstash.box"
The upload is git ls-files plus untracked files, minus what .gitignore ignores, minus:
.git, dependency and build directories, .aws, .ssh, .gnupg.env files except examples and samplesid_rsa, .npmrc, .pemThe start pane lists every excluded file with its reason before the box is created. Use allowSensitivePaths for an exact file you want anyway, and excludedPaths for more to leave out.
Decisions with a rationale worth keeping live in docs/adr.
herdr label and a hm:<mapping id> label, so a mapping can find its box again even if the state file is lost, and recovery refuses to guess when more than one box matches. The dashboard only calls a box an orphan when it carries both labels and no mapping claims it by id, label, or name, and deletion re-checks that against fresh state.maxPatchBytes. The bytes are bound to that commit by a checksum computed in the box.allowSensitivePaths. The patch applies locally only when git apply --check passes, and one Apply runs per box at a time.Shipped: setup, the lifecycle and the agent pane, worktree upload and patch-back, the dashboard with pause, resume, previews, snapshots and fork, and per-harness starts. Each piece was verified against real boxes and driven end to end inside a live Herdr session, and the setup popup was driven in a real terminal. Decisions with a rationale worth keeping are in docs/adr.
Requires @upstash/box 0.7.5 or newer, which the plugin installs itself.
The upload is too large. Start stops before creating a box and names the total, the file count, the heaviest top-level directories, and the five largest eligible files. The directories are usually the answer: on a docs repository the five largest files were 1 to 3 MB each while 110 MB sat in img/ across 438 files. The total is measured before file contents are scanned, so a large file the secret filter would have dropped still counts. Add the heavy paths to excludedPaths. Raising maxUploadBytes past 100 MB does not help, because Box rejects larger uploads.
Claude Code shows API Usage Billing instead of your plan. That session is not on the subscription token. The model is recorded per box, so a box created on an openrouter/ model stays on OpenRouter no matter what setup wrote later. Delete it and start a new one; the start pane names the credential before it creates anything.
A verb says another popup is already open. Herdr shows one popup at a time. Close the open one and try again; an unattended popup closes itself after two minutes.
npm run typecheck
npm test
npm run lint
Herdr plugin: run a coding agent in an Upstash Box from the worktree you are looking at
TypeScript
3
28 commits
updated Sep 10, 2026
Run a coding agent in an Upstash Box from the worktree you are looking at in Herdr.
Focus a pane inside a Git worktree, invoke Start agent in Upstash Box, and the plugin creates a box, uploads a filtered copy of the worktree, opens a new pane, and puts the agent in it. Close the pane and the agent keeps running. Come back with Reconnect, and a box that idled and paused in the meantime resumes on its own. When the agent has done something, Apply changes brings its edits back as a checked Git patch.
The pane runs the real Claude Code, Codex, or OpenCode terminal UI, kept alive under tmux inside the box. Your provider credential is passed into each session and never handed to the box.
herdr plugin install upstash/herdr-upstash-box
The install step fetches the Box SDK and CLI and compiles the plugin, so nothing needs to be on your PATH.
To work on it locally instead:
git clone https://github.com/upstash/herdr-upstash-box
cd herdr-upstash-box
npm install
npm run build
herdr plugin link "$(pwd)"
herdr plugin action invoke setup --plugin upstash.box
Setup opens a popup that asks for your Upstash Box API key and checks it against the API before anything else, then asks which agent and which provider credential to use, and writes config.json and secrets.json (mode 600) for you. Keys are typed without echo, so nothing lands in the scrollback. If you pick the subscription option and Claude Code is installed on this machine, setup runs claude setup-token for you: your browser opens to approve it, and the token is captured from the command's output without ever being shown. Otherwise you paste one from another terminal, and a line break inside the paste is fine. Either way the token is checked with Anthropic before it is saved. A credential that is already present can be kept or replaced, unless it comes from the environment: the environment outranks the file, so setup says to unset it there instead.
Everything setup writes can also be written by hand, as below.
Keys can come from the environment Herdr runs in, or from a secrets.json in the plugin config directory. The environment is enough when you start Herdr from a terminal; the file exists because Herdr launched from the Dock or Spotlight does not inherit your shell exports.
herdr plugin config-dir upstash.box
{
"UPSTASH_BOX_API_KEY": "...",
"CLAUDE_CODE_OAUTH_TOKEN": "...",
"ANTHROPIC_API_KEY": "...",
"OPENROUTER_API_KEY": "..."
}
CLAUDE_CODE_OAUTH_TOKEN is a subscription token from claude setup-token, so a Claude Pro or Max plan can drive Claude Code in the box without a pay-per-token Console key. For Claude Code on an anthropic/ model the plugin looks for CLAUDE_CODE_OAUTH_TOKEN first and ANTHROPIC_API_KEY second. Set providerApiKeyEnv to name one variable outright; then only that variable is read, and a missing value is an error rather than a fallback to some other key, so a session can never bill an account you did not choose. Whichever credential is in force, the competing Claude variables are blanked on the exec session so nothing inherited from the image can win.
Environment variables win over the file. The file must be a regular file that you own; if other users can read it, the plugin tightens it to mode 600 before reading.
Create config.json in the same directory. Every key is optional.
{
"harness": "claude-code",
"model": "anthropic/claude-sonnet-5",
"agentArgs": [],
"runtime": "node",
"size": "small",
"keepAlive": false,
"boxNamePrefix": "herdr",
"remoteRoot": "/workspace/home/worktree",
"providerApiKeyEnv": null,
"allowMultipleBoxes": false,
"excludedPaths": [],
"allowSensitivePaths": [],
"maxFiles": 10000,
"maxFileBytes": 10485760,
"maxUploadBytes": 104857600,
"maxPatchBytes": 52428800,
"previewPorts": [3000, 5173, 8000],
"previewAuth": "basic"
}
| Setting | Default | Purpose |
|---|---|---|
harness | claude-code | claude-code, codex, or opencode. Already installed in every box image. |
model | anthropic/claude-sonnet-5 | A Box model id with its provider prefix. Claude Code takes anthropic/ and openrouter/ models. Codex needs an openai/ model. OpenCode takes any. Incompatible pairs fail at config time. |
agentArgs | [] | Extra arguments appended to the harness command. ["--dangerously-skip-permissions"] lets Claude Code edit without approving each change, which is reasonable in a disposable box with a scoped key and wrong on a laptop; it is never the default for an interactive start. |
runtime | node | Box runtime image. |
size | small | small, medium, or large. |
keepAlive | false | Keep the box running instead of letting it pause when idle. |
boxNamePrefix | herdr | Prefix for generated box names. |
remoteRoot | /workspace/home/worktree | Where the worktree lands in the box. Must stay under /workspace. A subdirectory keeps the agent's own config directories out of the Git baseline. |
providerApiKeyEnv | null | Name the one variable that carries the provider credential. By default the plugin tries the variables that fit the harness and model, subscription token first for Claude Code. Set this and nothing else is ever substituted. |
allowMultipleBoxes | false | Allow more than one live box per worktree. Off by default, so a second Start points you at the existing box. |
excludedPaths | [] | Extra repository-relative paths left out of the upload. |
allowSensitivePaths | [] | Exact files the safety filter would otherwise exclude. |
maxFiles | 10000 | Upload file count limit. |
maxFileBytes | 10485760 | Per-file upload limit, 10 MiB. |
maxUploadBytes | 104857600 | Total upload limit, 100 MiB. |
maxPatchBytes | 52428800 | Largest patch Apply will download from the box, 50 MiB. |
previewPorts | [3000, 5173, 8000] | Ports the previews pane may expose with a public URL. |
previewAuth | basic | basic puts basic auth on every new public URL. none makes the link reachable by anyone who has it. |
Unknown keys and invalid values stop the action with a named error.
herdr plugin action invoke setup --plugin upstash.box
herdr plugin action invoke start-agent --plugin upstash.box
herdr plugin action invoke start-claude --plugin upstash.box
herdr plugin action invoke start-codex --plugin upstash.box
herdr plugin action invoke start-opencode --plugin upstash.box
herdr plugin action invoke reconnect --plugin upstash.box
herdr plugin action invoke apply-changes --plugin upstash.box
herdr plugin action invoke info --plugin upstash.box
herdr plugin action invoke stop --plugin upstash.box
herdr plugin action invoke delete-box --plugin upstash.box
herdr plugin action invoke pause --plugin upstash.box
herdr plugin action invoke resume --plugin upstash.box
herdr plugin action invoke snapshot --plugin upstash.box
herdr plugin action invoke fork --plugin upstash.box
herdr plugin action invoke previews --plugin upstash.box
herdr plugin action invoke dashboard --plugin upstash.box
A binding for Start:
[[keys.command]]
key = "prefix+shift+u"
command = "herdr plugin action invoke start-agent --plugin upstash.box"
Start claude, Start codex, and Start opencode are Start on a named harness for one launch, meant for key bindings: config.json is not touched, the configured model is kept when that harness can use it and otherwise the harness default applies, and agentArgs and providerApiKeyEnv are dropped because they were written for the configured harness. Herdr cannot pass arguments to an action, which is why these are three verbs rather than one flag. They are still one box per worktree: a second Start on a worktree that already has a live box is refused whichever verb you use. On reconnect, a providerApiKeyEnv written for the configured harness and model is ignored for a mapping it does not fit, whether a different harness or a different provider: a box created on an openrouter/ model keeps its OpenRouter key even after setup moved the config to a subscription token, because the model is recorded per box.
Start checks the worktree, refuses if a box already exists for it, then splits the focused pane, creates a box named after the worktree, uploads the filtered tree, records a Git baseline in the box, and opens the agent. Reconnect attaches again from any pane the mapping knows, resuming a paused box first and finishing any preparation a crash interrupted. If the pane Start was invoked from no longer exists, which is the case after any Herdr restart, Reconnect anchors to the focused pane instead and remembers it. Apply changes exports what changed in the box since the last apply as a binary Git patch, checks it against the worktree, shows the summary, and applies it after you say yes. Stop ends the agent session and keeps the box. Delete asks you to type DELETE in a popup, then removes the box and its mapping. Info shows the box status, agent session, paths, and export markers.
Pause ends the agent session and pauses the box; Resume brings it back without opening the agent. Snapshot saves the box state under a timestamped name. Fork asks you to type FORK, snapshots the box, and starts a second box from that snapshot for the same worktree, with its own mapping, so two directions can run from the same point. Previews exposes one of the configured ports with a public URL, with basic auth by default, and removes it again. Dashboard opens a zoomed board of every box the plugin owns.
Herdr shows one popup at a time. If a verb reports that another popup is already open, close that popup and run it again. A popup left unattended closes itself after two minutes so it cannot block the other verbs.
The board lists every mapping newest first, with worktree and branch, box name, agent, local lifecycle, live remote status, and age, and refreshes the remote column every few seconds. Boxes that carry the plugin label but have no mapping appear as orphans and can only be deleted. Every verb is one key away and opens the same pane the matching action would:
[j/k] Select [enter/r] Reconnect [a] Apply [i] Info [s] Stop [p] Pause [u] Resume
[n] Snapshot [f] Fork [v] Previews
[d] Delete [R] Refresh [q] Close
A binding for it:
[[keys.command]]
key = "prefix+shift+b"
command = "herdr plugin action invoke dashboard --plugin upstash.box"
The upload is git ls-files plus untracked files, minus what .gitignore ignores, minus:
.git, dependency and build directories, .aws, .ssh, .gnupg.env files except examples and samplesid_rsa, .npmrc, .pemThe start pane lists every excluded file with its reason before the box is created. Use allowSensitivePaths for an exact file you want anyway, and excludedPaths for more to leave out.
Decisions with a rationale worth keeping live in docs/adr.
herdr label and a hm:<mapping id> label, so a mapping can find its box again even if the state file is lost, and recovery refuses to guess when more than one box matches. The dashboard only calls a box an orphan when it carries both labels and no mapping claims it by id, label, or name, and deletion re-checks that against fresh state.maxPatchBytes. The bytes are bound to that commit by a checksum computed in the box.allowSensitivePaths. The patch applies locally only when git apply --check passes, and one Apply runs per box at a time.Shipped: setup, the lifecycle and the agent pane, worktree upload and patch-back, the dashboard with pause, resume, previews, snapshots and fork, and per-harness starts. Each piece was verified against real boxes and driven end to end inside a live Herdr session, and the setup popup was driven in a real terminal. Decisions with a rationale worth keeping are in docs/adr.
Requires @upstash/box 0.7.5 or newer, which the plugin installs itself.
The upload is too large. Start stops before creating a box and names the total, the file count, the heaviest top-level directories, and the five largest eligible files. The directories are usually the answer: on a docs repository the five largest files were 1 to 3 MB each while 110 MB sat in img/ across 438 files. The total is measured before file contents are scanned, so a large file the secret filter would have dropped still counts. Add the heavy paths to excludedPaths. Raising maxUploadBytes past 100 MB does not help, because Box rejects larger uploads.
Claude Code shows API Usage Billing instead of your plan. That session is not on the subscription token. The model is recorded per box, so a box created on an openrouter/ model stays on OpenRouter no matter what setup wrote later. Delete it and start a new one; the start pane names the credential before it creates anything.
A verb says another popup is already open. Herdr shows one popup at a time. Close the open one and try again; an unattended popup closes itself after two minutes.
npm run typecheck
npm test
npm run lint