Skip to content

WonderTwin in Shell / Docker Compose / Custom CI

WonderTwin in shell scripts, Docker Compose, or custom CI

Section titled “WonderTwin in shell scripts, Docker Compose, or custom CI”

The universal escape hatch. Works on your laptop, in make test, in Buildkite, Jenkins, TeamCity, Argo, or any CI platform that lets you run shell commands.

If your CI platform isn’t covered by github-actions.md, circleci.md, or gitlab-ci.md, this is the foundation everyone else’s snippet wraps.

Every WonderTwin CI integration boils down to four things, in order:

  1. Install wt — download the binary for your platform
  2. Install license — write the license JSON to disk and run wt license install
  3. Install + start the twinwt install <twin>@<version> then run it in background
  4. Wrap your testswt runs start → tests → wt runs finish --export

Capture the export as your CI’s artifact mechanism. That’s the integration.

run-tests.sh:

#!/usr/bin/env bash
set -euo pipefail
TWIN="${TWIN:-stripe}"
TWIN_VERSION="${TWIN_VERSION:-latest}"
TWIN_PORT="${TWIN_PORT:-4111}"
TWIN_URL="http://127.0.0.1:${TWIN_PORT}"
RUN_ID="${RUN_ID:-$(date +%s)-$$}"
ARTIFACT_DIR="${ARTIFACT_DIR:-./artifacts}"
mkdir -p "$ARTIFACT_DIR"
# 1. Install wt (skip if already on PATH)
if ! command -v wt >/dev/null 2>&1; then
case "$(uname -s)" in
Linux*) OS=linux ;;
Darwin*) OS=darwin ;;
*) echo "unsupported OS"; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) echo "unsupported arch"; exit 1 ;;
esac
curl -sSL "https://github.com/wondertwin-ai/wondertwin/releases/latest/download/wt-${OS}-${ARCH}" -o /usr/local/bin/wt
chmod +x /usr/local/bin/wt
fi
# 2. Install license (if provided)
if [[ -n "${WONDERTWIN_LICENSE:-}" ]]; then
printf '%s' "$WONDERTWIN_LICENSE" > /tmp/license.json
wt license install /tmp/license.json
rm /tmp/license.json
fi
# 3. Start the twin
wt install "${TWIN}@${TWIN_VERSION}"
wt up > /tmp/twin.log 2>&1 &
TWIN_PID=$!
cleanup() {
kill "$TWIN_PID" 2>/dev/null || true
sleep 1
kill -9 "$TWIN_PID" 2>/dev/null || true
}
trap cleanup EXIT
# Wait for twin
for i in $(seq 1 30); do
curl -sf "${TWIN_URL}/admin/state" > /dev/null && break
sleep 0.2
done
# 4. Run lifecycle
wt runs start --twin "$TWIN_URL" --seed 42 --run-id "$RUN_ID"
# Run your tests — they should hit $TWIN_URL
export WT_TWIN_URL="$TWIN_URL"
"${@:-make test}"
TEST_EXIT=$?
wt runs finish --twin "$TWIN_URL" --run-id "$RUN_ID" \
--export "${ARTIFACT_DIR}/wondertwin-replay.jsonl.gz" || true
exit $TEST_EXIT

Make executable, invoke with the test command:

Terminal window
chmod +x run-tests.sh
./run-tests.sh make test

docker-compose.test.yml:

services:
twin:
image: wondertwin/twin-<TWIN>:<TWIN_VERSION>
ports:
- "<TWIN_PORT>:<TWIN_PORT>"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:<TWIN_PORT>/admin/state"]
interval: 1s
timeout: 1s
retries: 30
tests:
image: <YOUR_TEST_IMAGE>
depends_on:
twin:
condition: service_healthy
environment:
WT_TWIN_URL: http://twin:<TWIN_PORT>
volumes:
- ./artifacts:/artifacts
command: |
sh -c '
wt runs start --twin $$WT_TWIN_URL --seed 42 --run-id $${RUN_ID:-local-$$$$}
<TEST_COMMAND>; rc=$$?
wt runs finish --twin $$WT_TWIN_URL --run-id $${RUN_ID:-local-$$$$} \
--export /artifacts/wondertwin-replay.jsonl.gz || true
exit $$rc
'

Twin Docker images aren’t published for every twin yet — see the twin’s README. For twins without a published image, run the binary directly in the test container or use the shell snippet above.

TWIN ?= stripe
TWIN_VERSION ?= latest
TWIN_PORT ?= 4111
TWIN_URL := http://127.0.0.1:$(TWIN_PORT)
RUN_ID := $(shell date +%s)-$$$$
ARTIFACT_DIR := ./artifacts
.PHONY: test
test:
@./run-tests.sh make test-inner
.PHONY: test-inner
test-inner:
# your actual test invocation here
go test ./...

The run-tests.sh from above handles the lifecycle; make test is the user-facing entry.

wt runs wrap for an even shorter shell snippet

Section titled “wt runs wrap for an even shorter shell snippet”

If you don’t need a separate test step, wrap collapses lifecycle + test command into one call:

Terminal window
wt runs wrap \
--twin "$TWIN_URL" \
--seed 42 \
--run-id "$RUN_ID" \
--export "${ARTIFACT_DIR}/wondertwin-replay.jsonl.gz" \
-- make test

wrap always finishes the run on exit, regardless of the wrapped command’s exit code.

If you need multiple twins, define a manifest and let wt up start them all:

wondertwin.toml
[[twins]]
name = "stripe"
version = "0.1.0"
port = 4111
[[twins]]
name = "twilio"
version = "0.1.0"
port = 4112
Terminal window
wt install
wt up &
# ... rest as before, but tests hit two URLs

The shell snippet writes to $ARTIFACT_DIR/wondertwin-replay.jsonl.gz. Each CI platform has its own artifact upload mechanism. Translate:

  • Buildkite: buildkite-agent artifact upload "artifacts/*"
  • Jenkins: the post-build action’s Archive the artifacts glob set to artifacts/**
  • TeamCity: ##teamcity[publishArtifacts 'artifacts => wondertwin']
  • Argo Workflows: outputs.artifacts: block referencing artifacts/wondertwin-replay.jsonl.gz
  • Tekton: Workspace mounted at /artifacts, fetched by a follow-up Task

The snippet stays the same; only the artifact upload command changes.

wt is published as platform-specific binaries on GitHub Releases. The download URL pattern is:

https://github.com/wondertwin-ai/wondertwin/releases/<TAG>/download/wt-<OS>-<ARCH>

Where <TAG> is latest or a specific version like v0.4.2, <OS> is linux / darwin / windows, and <ARCH> is amd64 / arm64 (no windows-arm64). The shell snippet detects OS/arch automatically.

For air-gapped environments: pre-bake wt into your CI image or download once and cache.

Licenses are signed JSON files. Customers receive them from sales. Install path:

Terminal window
wt license install /path/to/license.json

This copies to ~/.wondertwin/license.json (or $WONDERTWIN_HOME/license.json). The runtime reads from there at twin startup.

If you don’t have a license, twins still run — soft gate. The startup banner warns; telemetry attributes the run as unlicensed; replay artifacts are unaffected.

wt install <twin>@<version> fetches the twin binary into ~/.wondertwin/bin/. wt up reads wondertwin.toml (or the default lock file) and starts every twin listed.

For one twin without a manifest:

Terminal window
wt install stripe@0.1.0
~/.wondertwin/bin/twin-stripe & # default port 4111

For most CI cases, wt up & is the simplest answer.

The lifecycle endpoints are documented at /admin/runs/{start,finish,current}. The CLI verbs are wt runs start, wt runs finish, wt runs current, and wt runs wrap for the convenience-wrapper case. See concepts.md for the run-lifecycle model.

Confirm WT_TWIN_URL is exported AND your SDK respects the base-URL override. Some SDKs require a flag instead of an env var.

wt up blocks while serving traffic; it shouldn’t exit unless the twin crashes. If it does exit immediately:

  • Check /tmp/twin.log for an error
  • Confirm the twin is installed: ls ~/.wondertwin/bin/
  • Try running the binary directly: ~/.wondertwin/bin/twin-stripe (you’ll see the startup output)

6 seconds is enough for most twins. If yours takes longer, increase the loop count in the snippet. If it consistently exceeds 10 seconds, file an issue — that’s a regression.

The export step needs the run to have actually started. If the test step crashes before any request to the twin, the manifest will exist but with entry_count: 0 — that’s correct, not a bug. The export will still write the file.

If the file is truly missing, confirm wt runs finish ran (check the script’s log) and that the export path is writable.

Adjust the OS and ARCH detection in the snippet. Windows isn’t supported in the shell pattern (use WSL or a Linux container).