Stoa normally runs as a stdio MCP server: a local process that Claude Code (or any MCP client) forks directly. Server mode is an opt-in HTTP transport that makes the same tool surface reachable over the network, with bearer-token authentication and capability-bounded identities. It is intended for operators running multi-agent pipelines — Fargate workers, Agora dispatched processes, CI job fleets — that cannot share a process boundary with Stoa. Solo-laptop users running stoa --mcp are unaffected and require no changes.
The v0.4 Docker image is published as:
ghcr.io/brettnye/stoa:0.4.0
Note: this image path documents the canonical release coordinate. The image itself is built from the multi-stage Dockerfile at the root of this repo (
node:20-slimbase,CMD ["serve", "--bind=0.0.0.0:8443"]). It will be published on the first CI-triggered push to the release tag; pull will fail until then.
The vault is a plain filesystem tree. Stoa has no embedded database and makes no assumptions about the underlying block device.
| Deployment target | Recommended storage |
|---|---|
| AWS Fargate | Amazon EFS volume, mounted at /vault in the task definition |
| AWS ECS on EC2 | EBS volume, bind-mounted at /vault; pin the task to the instance |
| Local development | Host bind mount (-v ./my-vault:/vault) |
Set the vault path via the STOA_VAULT_PATH environment variable or the --vault= flag passed to stoa serve.
Stoa uses one environment variable for token signing and verification:
STOA_TOKEN_SIGNING_SECRET=<hex string>
The integrator generates this value, holds it out-of-band, and injects it into every Stoa container and into any process that mints tokens (the orchestrator). Stoa never exposes or rotates it.
Generate a fresh secret with:
openssl rand -hex 32Stoa serves plain HTTP. It does not terminate TLS itself.
- Production: place Stoa behind an ALB, nginx, or Caddy instance that handles TLS. Stoa binds
127.0.0.1:8443by default, so it is only reachable from the same host unless--bind=0.0.0.0:8443is set explicitly. - Local development:
127.0.0.1is the default bind address; TLS is optional.
Configure the external TLS terminator to forward plain HTTP to Stoa's bind port. Workers and the orchestrator communicate with Stoa over HTTPS at the terminator's address, never directly to Stoa's HTTP port.
Workers and the orchestrator must reach Stoa over HTTPS in a production deployment. Stoa itself does not manage discovery or DNS.
| Context | Recommended approach |
|---|---|
| AWS VPC | ALB with HTTPS listener; workers resolve the ALB DNS name |
| Local laptop demo | ngrok (ngrok http 8443) or Tailscale to tunnel the local port |
| CI / GitHub Actions | ngrok ephemeral URL or a small EC2/Fargate instance provisioned per run |
The following sequence initializes a fresh vault and mints a long-lived operator token. Run these steps before your first deployment.
# 1. Generate and export the signing secret
export STOA_TOKEN_SIGNING_SECRET=$(openssl rand -hex 32)# 2. Initialize the vault headlessly (non-interactive -y mode reads STOA_VAULT_PATH)
docker run --rm \
-v stoa-vault:/vault \
-e STOA_VAULT_PATH=/vault \
ghcr.io/brettnye/stoa:0.4.0 \
init -y# 3. Mint a long-lived operator token (printed to stdout; store securely)
docker run --rm \
-e STOA_TOKEN_SIGNING_SECRET="$STOA_TOKEN_SIGNING_SECRET" \
ghcr.io/brettnye/stoa:0.4.0 \
mint-token \
--agent-id=orchestrator \
--scope='vault_new:wikis/project-*/**,vault_task-create:wikis/project-*/**,vault_recall:*' \
--ttl=30dSave the token that mint-token prints to stdout. This is your operator credential; treat it as a secret.
{
"family": "stoa",
"containerDefinitions": [
{
"name": "stoa",
"image": "ghcr.io/brettnye/stoa:0.4.0",
"command": ["serve", "--bind=0.0.0.0:8443", "--vault=/vault"],
"portMappings": [{ "containerPort": 8443 }],
"environment": [
{ "name": "STOA_VAULT_PATH", "value": "/vault" }
],
"secrets": [
{
"name": "STOA_TOKEN_SIGNING_SECRET",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:stoa/signing-secret"
}
],
"mountPoints": [
{
"containerPath": "/vault",
"sourceVolume": "stoa-vault"
}
],
"healthCheck": {
"command": ["CMD-SHELL", "curl -sf http://localhost:8443/health || exit 1"],
"interval": 10,
"timeout": 5,
"retries": 3
}
}
],
"volumes": [
{
"name": "stoa-vault",
"efsVolumeConfiguration": {
"fileSystemId": "fs-xxxxxxxxx",
"rootDirectory": "/stoa"
}
}
]
}Attach an ALB HTTPS listener (port 443) that forwards to this task on port 8443.
docker run --rm \
-v "$(pwd)/my-vault:/vault" \
-e STOA_VAULT_PATH=/vault \
-e STOA_TOKEN_SIGNING_SECRET="$STOA_TOKEN_SIGNING_SECRET" \
-p 8443:8443 \
ghcr.io/brettnye/stoa:0.4.0 \
serve --bind=0.0.0.0:8443This is the recommended convention for integrators managing multiple workers.
Operator token — long-lived (30d), broad scopes. Held only by the orchestrator. Used to set up shared resources: create wikis, create tasks, assign profiles.
Worker token — short-lived (minutes to hours), narrow scopes. Minted by the orchestrator immediately before dispatching a worker. Handed to the worker as an environment variable. Scopes are limited to exactly what the specific work item requires.
Both tokens are HS256 JWTs signed with the same STOA_TOKEN_SIGNING_SECRET. Stoa verifies both identically — the tiering is an integrator convention, not a protocol distinction.
Example scope sets:
| Role | Example scopes |
|---|---|
| Operator | vault_new:wikis/project-*/**,vault_task-create:wikis/project-*/**,vault_recall:* |
| Worker (claim + update one task) | vault_task-claim:tasks/review-abc,vault_task-update:tasks/review-abc,vault_recall:wikis/project-beta/**,vault_agent-journal:wikis/project-beta/journal |
- Orchestrator authenticates to Stoa with the operator token.
- Orchestrator creates wikis and tasks as needed (
vault_task-create,vault_new). - Orchestrator locally mints a worker JWT with scopes narrowed to the specific task and relevant wiki paths. The signing key is the same
STOA_TOKEN_SIGNING_SECRET; no Stoa API call is required. - Orchestrator dispatches the worker (Fargate task, Agora sub-agent, etc.) with
STOA_TOKENset to the worker JWT andSTOA_SERVER_URLset to the ALB HTTPS endpoint. - Worker presents its bearer token to Stoa on every MCP call. Stoa stamps
agent_idfrom the verifiedsubclaim — the worker cannot forge its own identity. - Worker claims its task (
vault_task-claim), performs work, and updates the task (vault_task-update). Narrow scopes prevent it from touching other workers' tasks or unrelated wikis. - Worker token expires automatically; no revocation required.
Solo-laptop users running stoa --mcp are unaffected. The stdio transport continues to work without configuration changes. Server mode is strictly opt-in — nothing starts listening on a port unless you run stoa serve.
v0.4 breaking change: agent_id has been removed from the input schemas of all write tools (vault_channel-post, vault_agent-journal, vault_task-claim, vault_task-update, vault_task-create, vault_claim, vault_agent-memory). Callers that pass agent_id explicitly will receive a Zod parse error. The server now stamps agent_id from the verified principal.
To find affected call sites in your repository, run:
stoa lintand look for AGENT_ID_INPUT_LEAK codes. See CHANGELOG.md for the full breaking-change notice.
Stoa exposes a health endpoint at:
GET /health- Returns
200 OKwhen the vault path exists and is readable. - Returns
503 Service Unavailableotherwise.
Use this endpoint for ALB target group health checks and Fargate liveness/readiness probes.
The container started but has no vault to operate against. Either STOA_VAULT_PATH is unset, or no --vault= flag was passed, or the named volume isn't mounted.
# Wrong — no vault path:
docker run --rm stoa-local
# Right — env var + mounted volume:
docker run --rm -v stoa-vault:/vault -e STOA_VAULT_PATH=/vault stoa-localIf the volume exists but the path inside the container doesn't, double-check the -v <volume>:<container-path> and -e STOA_VAULT_PATH=<container-path> match.
stoa serve fails at startup if the signing secret env var is missing. Generate one with openssl rand -hex 32 and inject it at run time. In production, source it from Secrets Manager / SSM Parameter Store / Vault — never bake it into the image.
docker run --rm -p 8443:8443 \
-v stoa-vault:/vault -e STOA_VAULT_PATH=/vault \
-e STOA_TOKEN_SIGNING_SECRET="$(openssl rand -hex 32)" \
stoa-localThe request did not present a valid Authorization: Bearer <jwt> header. Causes in rough order of likelihood:
- No
Authorizationheader at all. Add it. - JWT signed with a different secret than the running server. Rotate to the same secret on both sides, or mint a fresh token.
- Expired token. Check the
expclaim. Mint a fresh one (stoa mint-token --ttl=1h). - Malformed bearer. The header must be exactly
Authorization: Bearer <token>— no extra whitespace, no missingBearerprefix.
/health does not require auth — use it to verify the server itself is reachable independently of token issues.
The token verified, but its scopes don't grant the operation:
ScopeDeniedError(<axis>)— your token's scopes don't match the tool's axis for these inputs. Mint a broader token, or correct the path/wiki/task-id you're targeting.ScopeDeniedError("admin")— the tool requiresadmin:*(oradmin:<tool>) and the token lacks it. Admin tools includevault_reindex,vault_evolve-profile,vault_set-active,vault_new-wiki,vault_lint --scope=full, and map writes viavault_new.HttpForbiddenError— the tool is HTTP-forbidden entirely (vault_sync-skills,vault_sync-agents,vault_bootstrap-repo,vault_seed-substrate). Run those over stdio on a trusted host instead.
Docker caches layers and the image is built from dist/ (compiled). If you edited source on the host:
# Rebuild from worktree root:
cd C:/Users/brett/Documents/Knowledge/stoa/.worktrees/server-mode
docker build -t stoa-local .
# Then run the new image:
docker run --rm ... stoa-localFor a tight inner loop on local code changes, skip docker entirely:
npm run build
node dist/bin.js serve --vault=./my-vault --bind=127.0.0.1:8443The published image path is the canonical release coordinate but is published only on tagged release builds. Until a v0.4.0 tag has been cut and pushed, build the image locally with docker build -t stoa-local . from the repo root and use stoa-local in place of the published name.
stoa init -y scaffolds the vault structure but does not seed content. Either:
- Run
stoa --vault=/vault seed-substrate(or the equivalent inside the container) to install the bundled_agentscontent, or - Mount an existing populated vault into
/vault, or - Create your project wikis via
vault_new-wikifrom your orchestrator.
Tokens are bound to the exact secret used at signing time. Rotating STOA_TOKEN_SIGNING_SECRET invalidates every outstanding token. There is no revocation list in v0.4 — rotation is the only invalidation primitive. Plan rotations to coincide with operator-token expiry.
Git Bash on Windows (MINGW64, the shell that ships with Git for Windows) rewrites Unix-shaped paths before passing them to native Windows commands. docker.exe is a native Windows binary, so -e STOA_VAULT_PATH=/vault becomes -e STOA_VAULT_PATH=C:/Program Files/Git/vault inside the container. The server then reports /health as unhealthy because that mangled path doesn't exist.
Three fixes, pick one:
Option A — disable conversion per command:
MSYS_NO_PATHCONV=1 docker run --rm -p 8443:8443 \
-v stoa-vault:/vault \
-e STOA_VAULT_PATH=/vault \
-e STOA_TOKEN_SIGNING_SECRET="$STOA_TOKEN_SIGNING_SECRET" \
stoa-localOption B — disable conversion for the whole session:
export MSYS_NO_PATHCONV=1
# every subsequent docker command in this terminal works as-isOption C — double-slash escape (path-by-path):
docker run --rm -v stoa-vault://vault -e STOA_VAULT_PATH=//vault stoa-local init -yThe leading // tells MSYS "leave this alone." Linux inside the container treats //vault and /vault identically.
Option D — switch to PowerShell:
PowerShell doesn't do MSYS path conversion. The same docker run lines from this guide work as-is.
Both forms work with Docker Desktop, but watch for shell quoting on paths with spaces:
# Forward-slash form (Git Bash, PowerShell)
-v "C:/Users/brett/stoa-test-vault:/vault"
# Posix form (Git Bash auto-translates back to Windows for Docker)
-v "/c/Users/brett/stoa-test-vault:/vault"If your host path contains spaces, always quote the whole -v value. Combine with MSYS_NO_PATHCONV=1 to keep the container-side /vault half from getting rewritten.
docker run -e STOA_TOKEN_SIGNING_SECRET="$(openssl rand -hex 32)" generates a fresh secret each time. The server you started yesterday and the mint-token you ran today will have different secrets — tokens won't verify.
Save the secret to your shell env (or a file) once and reuse it across the session:
export STOA_TOKEN_SIGNING_SECRET=$(openssl rand -hex 32)
echo $STOA_TOKEN_SIGNING_SECRET # verify it's set
# Server and any subsequent mint-token calls now share the same secret:
docker run --rm -p 8443:8443 -v stoa-vault:/vault \
-e STOA_VAULT_PATH=/vault \
-e STOA_TOKEN_SIGNING_SECRET="$STOA_TOKEN_SIGNING_SECRET" \
stoa-local
# In another tab — note exports don't cross shells, so paste the value:
export STOA_TOKEN_SIGNING_SECRET="<paste the 64-hex string>"
docker run --rm -e STOA_TOKEN_SIGNING_SECRET="$STOA_TOKEN_SIGNING_SECRET" \
stoa-local mint-token --agent-id=tester --scope='vault_recall:*' --ttl=1hFor multi-tab workflows, save the secret to a file and source it:
echo "export STOA_TOKEN_SIGNING_SECRET=$(openssl rand -hex 32)" > ~/.stoa-dev-secret
chmod 600 ~/.stoa-dev-secret
source ~/.stoa-dev-secret # do this in every tab that talks to the serverFor the complete design rationale — authentication model, scope grammar, session lifecycle, task-claim locking, tool axis declarations, and multi-tenant deployment posture — see the spec:
docs/superpowers/specs/2026-05-21-stoa-server-mode-design.md