# Sandstorm leaves /query unauthenticated by default, and the container, the pip install and the docs do not agree

> An MIT-licensed Python runtime that puts a Claude-style agent behind a CLI, an HTTP API and a Slack bot, with a fresh sandbox per thread and OpenTelemetry export. The sandboxing, replay and trace plumbing is unusually complete. The access control is optional, the default runtime is a third-party cloud, and the install you get from pip is not the install the README describes.

**tomascupr/sandstorm** — Run Claude agents in secure cloud sandboxes — via API, CLI, or Slack. One call. Full agent. Zero infrastructure.

- Repository: https://github.com/tomascupr/sandstorm
- Website: https://duvo.ai
- Stars: 447 · Forks: 43
- Language: Python
- License: MIT
- Published: 2026-09-10 · Updated: 2026-09-10 · Language: en
- Canonical page: https://hysenlabs.com/projects/tomascupr-sandstorm

## /query is open unless you uncomment the line that closes it

The access control on this server is opt-in, and the sample configuration file states it plainly. Under a heading marked optional, it explains that setting a variable requires a Bearer token on the /query endpoint, and that omitting it or leaving it empty gives open access, with local development given as the example.

So a deployment that follows the quickstart and stops there serves an unauthenticated agent endpoint. Anyone who can reach the port can submit prompts, run tools in a sandbox, and read the artifacts back.

What closes it is one commented line in .env.example:

```
# Set to require Bearer token auth on /query endpoint.
# Omit or leave empty for open access (e.g. local development).
# SANDSTORM_API_KEY=your-secret-token-at-least-32-chars
# SANDSTORM_API_KEY_PREVIOUS=old-key-for-rotation
```

Two details make it harder to get wrong than the phrasing suggests. The commented value carries its own minimum length, at least 32 characters, so a weak key is visible in the sample. And a previous-key variable exists for rotation, so swapping keys does not require a window where the endpoint is unprotected.

That the sample is honest about the default is worth saying plainly, because it is the kind of default that reaches production unnoticed. The safe move is to set the variable before the first request rather than after an incident, and to check the bind address at the same time.

```bash
CMD ["ds", "serve", "--host", "0.0.0.0", "--port", "8000"]
```

The container starts on all interfaces, not loopback. Open endpoint plus all-interfaces bind is the combination that matters.

## A request body key overrides the one the server was configured with

The same sample file states the precedence rule for credentials: request body over environment variable. Set a key in the environment for simple usage, or pass it per request to override.

That is a useful feature for multi-tenant or per-team deployments, where the server operator does not hold the caller's provider key. It is also a delegation of trust, and the sample file does not add a caveat about it. A caller who supplies their own key chooses which account the sandbox bills and which data boundary the request sits inside, so the server stops being the policy point for provider selection on that request.

Whether that is acceptable depends entirely on whether the endpoint is authenticated. Combined with the previous section, the two defaults compose badly: an open endpoint where the caller can also override the provider key is not a private agent runner.

The provider configuration that follows the same precedence note is five separate commented blocks, each one a hand-written shape rather than a plugin: Google Vertex AI behind CLAUDE_CODE_USE_VERTEX with a region, a project id and a service-account credentials path; Amazon Bedrock behind CLAUDE_CODE_USE_BEDROCK with a region and access key; Microsoft Azure Foundry behind CLAUDE_CODE_USE_FOUNDRY with a resource and an API key; OpenRouter behind an ANTHROPIC_BASE_URL override plus its own key; and a custom proxy block that is the same base URL variable pointed at localhost.

Every one of them is commented out by default. There is no provider selector to flip, only five blocks to fill in.

## A plain pip install gets neither Slack nor traces, and the container gets both

The headline sentence on the page promises streaming, file uploads, replay, and OpenTelemetry traces out of the box. The packaging says otherwise.

The base dependency list is nine packages: fastapi, uvicorn with the standard extra, e2b, sse-starlette, pydantic, python-dotenv, click and croniter, plus python-dotenv already counted. Neither OpenTelemetry nor Slack is in it. Both live in optional extras named telemetry and slack, along with client for httpx and dev for pytest, ruff, pyright and pre-commit.

So `pip install duvo-sandstorm` produces a server with no Slack bot and no trace export. The Slack section of the quickstart is explicit about this, installing the extra first:

```bash
pip install "duvo-sandstorm[slack]"
ds slack setup             # interactive, opens Slack app install in your browser
ds slack start             # Socket Mode (dev), use --http for production
```

That comment about Socket Mode being for development and --http for production is the kind of detail that changes a deployment decision, since Socket Mode keeps an outbound websocket to Slack rather than exposing an inbound endpoint.

The container resolves the ambiguity in the opposite direction from pip. The Dockerfile installs the extras explicitly:

```bash
RUN pip install --no-cache-dir ".[slack,telemetry]"
```

with a comment explaining that the default container should run the Slack bot out of the box and export traces, and that users who do not need them just ignore the extra dependencies. So the container is the richer artifact and the pip install is the lean one, from the same project at the same version. If you compare a hosted deployment to a local one, the trace and Slack surfaces will not match until you reconcile the extras.

One more asymmetry: the Slack extra installs slack-bolt, slack-sdk and aiohttp. The base install has no async HTTP client at all, which is consistent with a Slack-free core.

## The default sandbox is E2B, which is a third party, and the credentials prove it

Self-hosting is the argument the project makes against the closed alternatives, and its central claim is that data stays in your network. The default runtime complicates that.

A fresh sandbox per thread is created on the configured runtime, E2B by default, and e2b is a hard dependency in the base install rather than an extra. The credentials the preflight checks are ANTHROPIC_API_KEY and E2B_API_KEY, so a second vendor's key is required before the first real query even when everything runs inside your own container.

The README is careful about this. It says the default E2B runtime is available self-hosted, which is a distinct claim from E2B being self-hosted by default. Running your own E2B deployment is supported. Pointing at someone else's is the path taken by doing nothing, and that is where sandbox traffic goes until you change it.

That distinction matters because a sandbox is exactly the component holding the agent's working files, its installed packages and its shell. The thread-continuity design makes this concrete: each thread keeps its own paused sandbox, so uploaded files, generated outputs and installed packages survive across messages and across server restarts. Wherever that paused sandbox lives is wherever a thread's accumulated state sits between messages.

So the first configuration decision for a self-hosted deployment is not Slack and not the model. It is which runtime creates the sandbox, and the sample configuration does not document a switch for it.

## v0.9.1 appears six times in the documentation and zero times in the release list

The release history is short and has a gap in it. v0.8.1 shipped on 2026-03-12. v0.9.0 shipped on 2026-04-17 at 06:00. The third tag is v0.9.2, also on 2026-04-17, and its release name reads as a re-ship of v0.9.1 for a CI retry.

So v0.9.1 was never published. Its content is what v0.9.2 contains, under a different number, which is a reasonable recovery from a failed pipeline and a confusing thing to read about.

The confusion compounds because the documentation repeatedly attributes features to v0.9.1 specifically. The workspace-wide memory command, the channel-scoped memory command, the cancel command for an in-flight run, the reaction triggers, and the App Home tab are each labelled v0.9.1. There is a whole section headed Triggers (v0.9.1).

A reader checking whether a feature is available looks for a v0.9.1 release, finds none, and has to infer that v0.9.2 carries it. Anyone pinning to the tag named in the documentation gets nothing.

The package metadata is consistent with the release list rather than with the prose: pyproject declares version 0.9.2, matching the published tag and not the version the feature annotations cite. The distribution is also named duvo-sandstorm, and the homepage for this project is duvo.ai, so the package, the docs and the repository name are three different strings for one thing.

The last push was 2026-04-24, about a week after the final tag, so there are unreleased commits on main as well.

## Dockerfile, Procfile and vercel.json ship together, and the Vercel column contradicts the prose

The top level carries three deployment manifests: a Dockerfile, a docker-compose.yml and a Procfile, plus a vercel.json, a deploy/ directory, a build_template.py and a sandstorm.json. That is four hosting shapes for a project whose pitch is that you deploy it yourself on your terms.

The compose file is minimal and does one thing well: it builds, maps 8000 to 8000, loads .env, and defines a healthcheck against http://localhost:8000/health on a 30 second interval with a 5 second timeout, 3 retries and a 10 second start period. The Dockerfile mirrors that healthcheck.

What compose does not have is a volume. No named volume and no bind mount for state, so anything the runtime persists outside the container filesystem is not preserved by that file. For a tool whose selling point is a sandbox that survives a restart, the persistence story is not in the compose example.

The container also runs as a non-root user created during the build, which is the right default, and it inherits the open-auth default described earlier.

The comparison table has an internal contradiction worth flagging. The prose argues that neither of the two closed alternatives gives you other LLMs. The table then marks multi-provider support as absent for Claude Managed Agents, present for Vercel Slack Agent Skill, and absent for claude-code-slack-bot. So the table credits Vercel with the exact capability the prose denies it. One of the two is wrong, and the table is the artifact a reader is most likely to quote.

The same table marks runtime client source in repo as present for Sandstorm and also present for Vercel, which is consistent, and OSS license as MIT for Sandstorm, Proprietary for Claude Managed Agents, Apache templates for Vercel, MIT for claude-code-slack-bot.

## Each thread holds a paused sandbox that outlives the server process

Thread continuity is the mechanism that makes a Slack agent feel like one agent rather than a series of unrelated requests, and it has a specific implementation.

Each thread keeps its own paused sandbox on the configured runtime, E2B by default. Uploaded files, generated outputs, and installed packages survive across messages, and the documentation says they survive even across server restarts.

That last clause is the consequential one. State outliving the process means the sandbox is not owned by a container, a volume or a temp directory. It is owned by the runtime service and is addressable per thread. Two consequences follow. First, cost and cleanup are not local concerns, because the sandbox is paused rather than destroyed, and nothing in the visible documentation describes a per-thread TTL or a reap command. Second, deleting the Slack channel or the bot does not obviously delete the sandbox.

The Slack surface is otherwise complete for v0.9.x. An @Sandstorm mention can pick up context from a pull request reference and post a review. There are three memory scopes, personal with /remember, workspace-wide with /team-remember, and channel-scoped with /channel-remember. /model sets a per-thread model override, so two threads in one channel can run different models. The App Home tab shows memories, active runs, channel defaults and triggers.

Triggers are the part that turns it into a scheduled system rather than a chat toy. A cron entry with a schedule and a prompt, a webhook entry with a path, a secret interpolated from the environment and a prompt templated from the request body, and a reaction entry keyed on an emoji. Sub-hourly cron is supported here, which the documentation contrasts with a one-hour minimum enforced elsewhere.

The example run in the page reports 14 turns at a cost of $0.0731 and 47.2 seconds. Those figures come from an illustrative transcript, not from a benchmark, so treat them as the shape of the output rather than a performance characteristic.

## Conclusion

Use sandstorm if you need a Slack-resident agent on infrastructure you control and you are willing to configure provider credentials by hand, since self-hosting is the point and the Slack surface is complete. Do not expose the default deployment as it stands, because /query is unauthenticated until you set SANDSTORM_API_KEY and the container binds 0.0.0.0. Verify first that a plain `pip install duvo-sandstorm` is enough for your use, because the Slack bot and the OpenTelemetry exporters are optional extras that the default install omits and the container includes.

## FAQ

### Does sandstorm require authentication on its /query endpoint?

Not by default. The sample configuration describes setting SANDSTORM_API_KEY to require a Bearer token on /query, and says that omitting it or leaving it empty gives open access. The commented value specifies at least 32 characters, and SANDSTORM_API_KEY_PREVIOUS exists for rotation.

### What sandbox runtime does sandstorm use by default?

E2B by default, with e2b as a hard dependency in the base install rather than an optional extra. The preflight checks for both ANTHROPIC_API_KEY and E2B_API_KEY. The documentation states the default E2B runtime is available self-hosted, and each thread keeps its own paused sandbox on the configured runtime.

### Does a plain pip install of duvo-sandstorm include Slack and OpenTelemetry?

No. Slack lives in the slack extra with slack-bolt, slack-sdk and aiohttp, and the OpenTelemetry exporters live in the telemetry extra. The quickstart installs duvo-sandstorm[slack] for the Slack bot, while the Dockerfile installs .[slack,telemetry] so the container has both.

### Which model providers can sandstorm use?

Anthropic direct, plus OpenRouter, Google Vertex AI, Amazon Bedrock, Microsoft Azure Foundry and any OpenAI-compatible base URL. Each is a commented configuration block in the sample environment file, such as CLAUDE_CODE_USE_BEDROCK with an AWS region and keys, or ANTHROPIC_BASE_URL pointed at OpenRouter with its own key.

### Is there a v0.9.1 release of sandstorm?

No. The published tags are v0.8.1, v0.9.0 and v0.9.2, and v0.9.2 is named as a re-ship of v0.9.1 for a CI retry. The documentation still labels several Slack features as v0.9.1, so that version number appears in the docs but not in the release list. The package metadata declares version 0.9.2.

## Sources

- [License: MIT](https://github.com/tomascupr/sandstorm/blob/main/LICENSE)
- [Project website](https://duvo.ai)
- [README](https://github.com/tomascupr/sandstorm/blob/main/README.md)
- [Releases](https://github.com/tomascupr/sandstorm/releases)
- [tomascupr/sandstorm on GitHub](https://github.com/tomascupr/sandstorm)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tomascupr-sandstorm
