APIs watched
11
9 API providers and 2 MCP servers, plus your own OpenAPI and GraphQL contracts.
- Stripe
- Shopify
- OpenAI
- Anthropic
- Gemini
- Mistral
- HubSpot
- GitHub
- Twilio
An API you use changes. A pull request with the fix shows up.
Works with
Agents write the fix; editors get a PR when a tracked MCP server renames a tool. Trademarks belong to their owners; no endorsement implied.
01 / How it works
OpenAI retires gpt-4-0613 on 2026-10-23 for
gpt-5.6-sol.
01 · Vendor change
Upseam reads vendor changelogs every hour, and your own contracts on every push.
2026-04-22
Legacy GPT model snapshots
Access to these models will be shut down on the dates below.
| Model | Shutdown | Replacement |
|---|---|---|
| gpt-3.5-turbo-0125 | 2026-10-23 | gpt-5.6-terra |
| gpt-4-0613 | 2026-10-23 | gpt-5.6-sol |
02 · Affected code
Down to the file and line. No match, no noise.
import OpenAI from "openai";const client = new OpenAI();export async function answer(question: string) { const reply = await client.chat.completions.create({ model: "gpt-4-0613", messages: [{ role: "user", content: question }], });
2 matches in acme/ai-app
src/chat.ts:7 gpt-4-0613
fixable
src/chat.ts:22 Assistants API
needs you
03 · Pull request
Your model writes the fix in your conventions. What it can’t change safely is listed. Your CI runs on it.
Replace retired OpenAI model gpt-4-0613 with gpt-5.6-sol #57
Changes
src/chat.ts line 7
- model: "gpt-4-0613",
+ model: "gpt-5.6-sol",
Not changed: needs you
src/chat.ts line 22 calls the Assistants API, retired on
2026-08-26.
Verification
Upseam checks passed: 1 file, 1 line. Your CI: pending.
04 · Slack
In ask mode nothing is pushed until someone presses Open PR. Merge stays in GitHub.
Illustrative run on published data. Source: OpenAI deprecations
02 / Features
Your GitHub, your Slack, your model or your agent.
01 / Match
JavaScript/TypeScript, Python and Go.
02 / Detect
OpenAI retires gpt-4-0613; the pull request moves to gpt-5.6-sol.
03 / Review
Upseam opens the pull request. It never merges.
04 / Patch
Small diffs, next to the matched lines.
05 / Watch
Stripe, OpenAI, Anthropic, Mistral, Shopify, GitHub.
06 / Model
Billed by your provider.
Trademarks belong to their owners; no endorsement implied.
03 / Coverage
Mechanical fixes arrive as pull requests. Everything else is flagged.
11
9 API providers and 2 MCP servers, plus your own OpenAPI and GraphQL contracts.
2,216
Change events in the catalog, from the providers' own changelogs and release notes.
Trademarks belong to their owners; no endorsement implied.
01 / Breaking changes
Mechanical changes get a fix. Behavior changes are flagged for you, never guessed.
02 / New capabilities
A pull request to the named replacement, with the parameters to review. Pinned API versions rise to what your SDK supports.
03 / With Dependabot
When a Dependabot or Renovate bump breaks code: one PR with both.
04 / Your own contracts
Change an OpenAPI or GraphQL contract; every consumer gets a PR.
04 / Guardrails
Six rules, on the pull request and around it.
01 / Code
We never store your code.
02 / Key
Billed by your provider.
03 / Diff
Edits stay next to the matched lines.
04 / Checks
05 / Merge
06 / Close
It won’t reopen.
05 / Connect
Upseam finds the change and the code it touches. The fix comes from the model you choose or from the coding agent you already use.
01 / Via API
Nothing to add to your repo: paste your key on the settings page and enter the model id.
Model is the id as your vendor names it. Upseam keeps no list of models.
.github/workflows/upseam-generate.ymlYAML
- uses: upseam/action@<40-character commit SHA> # pin a release
with:
vendor: anthropic
model: <model id>
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
02 / Your agent
Claude Code, Codex, OpenCode, Hermes Agent or OpenClaw gets the task and edits the files.
1. Add the setting.github/upseam.yml
agent: claude-code2. Add the workflow.github/workflows/
name: upseam-agent
on:
repository_dispatch:
types: [upseam-agent]
permissions: {}
concurrency: upseam-agent-${{ github.event.client_payload.group }}
env:
FILES: ${{ toJSON(github.event.client_payload.files) }}
jobs:
agent:
if: github.event.client_payload.runner == 'claude-code'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.client_payload.sha }}
persist-credentials: false
- uses: anthropics/claude-code-action@8cf3482550831fb35a4fc3fbf7ca139cf8028b4c # v1.0.233
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ github.token }}
allowed_bots: ${{ github.actor }}
prompt: ${{ github.event.client_payload.task }}
settings: '{"permissions":{"blockReadsOutsideWorkingDirectories":true,"deny":["Read(//proc/**)","Read(//sys/**)","Read(//etc/**)","Read(//tmp/**)","Read(./.git/**)","Edit(./.git/**)","Edit(./.github/**)","Edit(//home/runner/work/_actions/**)","Edit(//home/runner/work/_temp/**)","Edit(//tmp/**)"]}}'
claude_args: --allowedTools Read,Edit,Glob,Grep --disallowedTools Bash,WebFetch,WebSearch --max-turns 30
- run: rm -rf .upseam-check
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.client_payload.sha }}
path: .upseam-check
persist-credentials: false
- name: Copy the listed files into the fresh checkout
run: |
set -euo pipefail
total=0
while IFS= read -r -d '' f; do
case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
if [ -f "$f" ] && [ ! -L "$f" ] && [ -f ".upseam-check/$f" ] && [ ! -L ".upseam-check/$f" ]; then
total=$((total + $(stat -c %s -- "$f")))
[ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
cp -- "$f" ".upseam-check/$f"
fi
done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
- uses: upseam/action@<40-character SHA> # v0.x, after the first release
with:
path: .upseam-check
check-only: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upseam-edits
path: ${{ runner.temp }}/upseam-edits
include-hidden-files: true
if-no-files-found: error
retention-days: 1
push:
needs: agent
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.client_payload.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: upseam-edits
path: ${{ runner.temp }}/upseam-edits
- name: Copy the listed files over the checkout
run: |
set -euo pipefail
src="$RUNNER_TEMP/upseam-edits"
total=0
while IFS= read -r -d '' f; do
case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
if [ -f "$src/$f" ] && [ ! -L "$src/$f" ] && [ -f "$f" ] && [ ! -L "$f" ]; then
total=$((total + $(stat -c %s -- "$src/$f")))
[ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
cp -- "$src/$f" "$f"
fi
done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
- uses: upseam/action@<40-character SHA> # v0.x, after the first releaseWhat happens
Claude Code gets only the Read, Edit, Glob and Grep tools; Bash, WebFetch and WebSearch are disallowed.
1. Add the setting.github/upseam.yml
agent: codex2. Add the workflow.github/workflows/
Replace the if and the Claude Code step of the agent job with:
if: github.event.client_payload.runner == 'codex'
# ...
- uses: openai/codex-action@86365089eb2b84e0a8fb0717b304f8bdcb13b20e # v1.12
with:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
prompt: ${{ github.event.client_payload.task }}
permission-profile: ":workspace"
allow-bot-users: ${{ github.actor }}What happens
Codex runs in the :workspace permission profile, and the action removes sudo before Codex runs.
1. Add the setting.github/upseam.yml
agent: opencode2. Add the workflow.github/workflows/
Set MODEL to provider/model, and replace the agent job with this one; the rest of the workflow stays the same:
jobs:
agent:
if: github.event.client_payload.runner == 'opencode'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.client_payload.sha }}
persist-credentials: false
- name: Install OpenCode 1.18.32 and ripgrep 15.1.0
run: |
set -euo pipefail
[ -z "${XDG_DATA_HOME:-}" ] && [ "$HOME" = /home/runner ] || { echo "::error::The OpenCode job needs a GitHub-hosted Ubuntu runner without XDG_DATA_HOME."; exit 1; }
dir="$RUNNER_TEMP/opencode"
mkdir -p "$dir"
curl -fsSL --proto '=https' -o "$dir/opencode.tgz" https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.32.tgz
echo "0886adbe8ca2f15e5ae7ac72c3a6549ec2b107dc2a60866a7cd7aa50d5a10416386843409bdd3b2cfd315c0c0bead4747186ea365dbe0efc7c98ea972316c379 $dir/opencode.tgz" | sha512sum -c -
tar -xzf "$dir/opencode.tgz" -C "$dir" package/bin/opencode
curl -fsSL --proto '=https' -o "$dir/rg.tgz" https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-unknown-linux-musl.tar.gz
echo "1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599 $dir/rg.tgz" | sha256sum -c -
bin="${XDG_CACHE_HOME:-$HOME/.cache}/opencode/bin"
mkdir -p "$bin"
tar -xzf "$dir/rg.tgz" -C "$bin" --strip-components=1 ripgrep-15.1.0-x86_64-unknown-linux-musl/rg
config="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
mkdir -p "$config/node_modules"
echo '{"dependencies":{"@opencode-ai/plugin":"1.18.32"}}' > "$config/package.json"
echo '{"lockfileVersion":3,"packages":{"":{"dependencies":{"@opencode-ai/plugin":"1.18.32"}}}}' > "$config/package-lock.json"
- name: Run OpenCode
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
MODEL: anthropic/<model id>
TASK: ${{ github.event.client_payload.task }}
OPENCODE_CONFIG_CONTENT: '{"autoupdate":false,"share":"disabled","snapshot":false,"lsp":false,"formatter":false,"permission":{"*":"deny","read":{"*":"allow","*.env":"deny","*.env.*":"deny",".git":"deny",".git/*":"deny"},"glob":"allow","grep":"allow","edit":{"*":"allow",".git":"deny",".git/*":"deny",".github/*":"deny","opencode.json":"deny","opencode.jsonc":"deny",".opencode/*":"deny"},"bash":"deny","webfetch":"deny","websearch":"deny","task":"deny","skill":"deny","question":"deny","lsp":"deny","external_directory":{"*":"deny","/home/runner/.local/share/opencode/tool-output/*":"deny"},"doom_loop":"deny"},"agent":{"build":{"steps":30}}}'
OPENCODE_DISABLE_PROJECT_CONFIG: "1"
OPENCODE_DISABLE_AUTOUPDATE: "1"
OPENCODE_DISABLE_MODELS_FETCH: "1"
OPENCODE_DISABLE_LSP_DOWNLOAD: "1"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "1"
OPENCODE_DISABLE_CLAUDE_CODE: "1"
run: |
set -euo pipefail
rm -rf opencode.json opencode.jsonc .opencode
find . -path ./.git -prune -o -type l -exec rm -f -- {} +
printf '%s' "$TASK" | "$RUNNER_TEMP/opencode/package/bin/opencode" --pure run --model "$MODEL" --agent build
- run: rm -rf .upseam-check
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.client_payload.sha }}
path: .upseam-check
persist-credentials: false
- name: Copy the listed files into the fresh checkout
run: |
set -euo pipefail
total=0
while IFS= read -r -d '' f; do
case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
if [ -f "$f" ] && [ ! -L "$f" ] && [ -f ".upseam-check/$f" ] && [ ! -L ".upseam-check/$f" ]; then
total=$((total + $(stat -c %s -- "$f")))
[ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
cp -- "$f" ".upseam-check/$f"
fi
done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
- uses: upseam/action@<40-character SHA> # v0.x, after the first release
with:
path: .upseam-check
check-only: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upseam-edits
path: ${{ runner.temp }}/upseam-edits
include-hidden-files: true
if-no-files-found: error
retention-days: 1What happens
OpenCode may only read, search and edit files in the repository: no shell, no web access, no subagents.
1. Add the setting.github/upseam.yml
agent: hermes2. Add the workflow.github/workflows/
Create the Actions secret ANTHROPIC_API_KEY with an Anthropic API key and replace the agent job of the workflow with this one; the push job stays the same.
jobs:
agent:
if: github.event.client_payload.runner == 'hermes'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
env:
HERMES_SHA: f97608f178d1ffeca59860195ab7da295f7c8e5f
IMAGE: ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b
PROVIDER: anthropic
MODEL: claude-sonnet-4-6
PROVIDER_HOST: api.anthropic.com
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.client_payload.sha }}
path: .upseam-check
persist-credentials: false
- name: Copy the listed files into the sandbox directory
run: |
set -euo pipefail
work="$RUNNER_TEMP/upseam-work"
mkdir -p "$work"
root="$(realpath -- .upseam-check)"
while IFS= read -r -d '' f; do
case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
if [ -f ".upseam-check/$f" ] && [ "$(realpath -e -- ".upseam-check/$f")" = "$root/$f" ]; then
mkdir -p -- "$work/$(dirname -- "$f")"
cp -- ".upseam-check/$f" "$work/$f"
fi
done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
chmod -R a+rwX "$work"
- name: Build Hermes Agent from its commit
run: |
set -euo pipefail
src="$RUNNER_TEMP/hermes-src"
git init -q "$src"
git -C "$src" fetch -q --depth 1 https://github.com/NousResearch/hermes-agent.git "$HERMES_SHA"
git -C "$src" -c advice.detachedHead=false checkout -q FETCH_HEAD
[ "$(git -C "$src" rev-parse HEAD)" = "$HERMES_SHA" ]
docker build -q -t upseam-hermes --build-arg IMAGE -f - "$src" <<'DOCKERFILE'
ARG IMAGE
FROM $IMAGE
WORKDIR /opt/hermes
COPY . .
RUN uv sync --locked --no-dev --no-install-project --no-build --no-python-downloads --python 3.13 --extra anthropic && printf 'docker\n' > .install_method
ENV PATH=/opt/hermes/.venv/bin:$PATH PYTHONDONTWRITEBYTECODE=1
DOCKERFILE
- name: Start the egress proxy
env:
PROXY_PY: |
import asyncio, os, time
ALLOW = set(os.environ["ALLOW"].split(","))
SLOTS = asyncio.Semaphore(32)
IDLE = 300
async def pipe(r, w, seen):
try:
while data := await r.read(65536):
seen[0] = time.monotonic()
w.write(data)
await w.drain()
w.close()
except OSError:
w.transport.abort()
async def handle(r, w):
if SLOTS.locked():
w.close()
return
async with SLOTS:
try:
head = await asyncio.wait_for(r.readuntil(b"\r\n\r\n"), 10)
method, target, _ = head.split(b"\r\n")[0].split(b" ")
host, port = target.decode("ascii").rsplit(":", 1)
if method != b"CONNECT" or port != "443" or host not in ALLOW:
raise PermissionError
ur, uw = await asyncio.wait_for(asyncio.open_connection(host, 443), 10)
except Exception:
print("deny", flush=True)
w.write(b"HTTP/1.1 403 Forbidden\r\n\r\n")
w.close()
return
print("allow", host, flush=True)
w.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
seen = [time.monotonic()]
tunnel = asyncio.gather(pipe(r, uw, seen), pipe(ur, w, seen))
while not tunnel.done():
await asyncio.wait([tunnel], timeout=10)
if time.monotonic() - seen[0] > IDLE:
uw.transport.abort()
w.transport.abort()
async def main():
server = await asyncio.start_server(handle, "0.0.0.0", 3128)
await server.serve_forever()
asyncio.run(main())
run: |
set -euo pipefail
docker network create --internal -o com.docker.network.bridge.gateway_mode_ipv4=isolated upseam-sandbox
docker network create upseam-egress
docker run -d --name upseam-proxy --network upseam-egress \
--read-only --cap-drop=ALL --security-opt no-new-privileges \
--user 65534:65534 --pids-limit 64 --memory 128m --memory-swap 128m \
-e ALLOW="$PROVIDER_HOST" \
"$IMAGE" python -c "$PROXY_PY"
docker network connect upseam-sandbox upseam-proxy
- name: Run Hermes Agent in the sandbox
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TASK: ${{ github.event.client_payload.task }}
run: |
set -euo pipefail
printf '%s' "$TASK" | docker run --rm -i --network upseam-sandbox \
--read-only --tmpfs /tmp:rw,nosuid,nodev,size=512m \
--cap-drop=ALL --security-opt no-new-privileges \
--user 65534:65534 --pids-limit 256 --memory 2g --memory-swap 2g \
-v "$RUNNER_TEMP/upseam-work:/work" -w /work \
-e HOME=/tmp -e HERMES_HOME=/tmp/hermes \
-e HTTPS_PROXY=http://upseam-proxy:3128 \
-e HERMES_WRITE_SAFE_ROOT=/work \
-e TIRITH_ENABLED=0 -e HERMES_DISABLE_LAZY_INSTALLS=1 \
-e ANTHROPIC_API_KEY \
upseam-hermes \
python /opt/hermes/hermes chat --safe-mode -Q --query-file - -t file \
--provider "$PROVIDER" --model "$MODEL" --max-turns 30 >/dev/null 2>&1 || status=$?
echo "Agent exit code: ${status:-0}"
exit "${status:-0}"
- name: Show the proxy log and remove the sandbox
if: always()
run: |
docker logs upseam-proxy || true
docker rm -f upseam-proxy || true
docker network rm upseam-sandbox upseam-egress || true
- name: Copy the edits into the checkout
run: |
set -euo pipefail
work="$(realpath -- "$RUNNER_TEMP/upseam-work")"
root="$(realpath -- .upseam-check)"
total=0
while IFS= read -r -d '' f; do
case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
if [ -f "$work/$f" ] && [ "$(realpath -e -- "$work/$f")" = "$work/$f" ] && [ -f ".upseam-check/$f" ] && [ ! -L ".upseam-check/$f" ] && [ "$(realpath -e -- ".upseam-check/$f")" = "$root/$f" ]; then
total=$((total + $(stat -c %s -- "$work/$f")))
[ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
cp -- "$work/$f" ".upseam-check/$f"
fi
done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
- uses: upseam/action@<40-character SHA> # v0.x, after the first release
with:
path: .upseam-check
check-only: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upseam-edits
path: ${{ runner.temp }}/upseam-edits
include-hidden-files: true
if-no-files-found: error
retention-days: 1What happens
1. Add the setting.github/upseam.yml
agent: openclaw2. Add the workflow.github/workflows/
Create the Actions secret ANTHROPIC_API_KEY and replace the agent job with this one; the push job stays the same.
jobs:
agent:
if: github.event.client_payload.runner == 'openclaw'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
env:
OPENCLAW_VERSION: 2026.9.6
OPENCLAW_INTEGRITY: sha512-Ie0kyQSCVfFqixsgVg39vevUDq01Ch5u3+7Yu5Y3qARczmdAe+lzp8bVnO9925rHiW/+CFp70zfORCyPmCH31g==
IMAGE: node:24.21.0-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6
PROXY_IMAGE: ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b
MODEL: anthropic/claude-sonnet-4-6
PROVIDER_HOST: api.anthropic.com
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
ref: ${{ github.event.client_payload.sha }}
path: .upseam-check
persist-credentials: false
- name: Copy the listed files into the sandbox directory
run: |
set -euo pipefail
work="$RUNNER_TEMP/upseam-work"
mkdir -p "$work"
root="$(realpath -- .upseam-check)"
while IFS= read -r -d '' f; do
case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
if [ -f ".upseam-check/$f" ] && [ "$(realpath -e -- ".upseam-check/$f")" = "$root/$f" ]; then
mkdir -p -- "$work/$(dirname -- "$f")"
cp -- ".upseam-check/$f" "$work/$f"
fi
done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
chmod -R a+rwX "$work"
- name: Build OpenClaw from its npm release
env:
OPENCLAW_CONFIG: |
{
update: { checkOnStart: false },
env: { shellEnv: { enabled: false } },
agents: {
defaults: {
sandbox: { mode: "off" },
skipBootstrap: true,
contextInjection: "never",
skills: [],
},
},
tools: {
profile: "coding",
allow: ["ls", "read", "write", "edit", "apply_patch"],
deny: ["group:runtime", "group:web", "group:ui", "group:sessions", "group:memory", "group:automation", "group:messaging", "group:nodes", "group:agents", "group:media", "group:openclaw", "group:plugins", "bundle-mcp"],
fs: { workspaceOnly: true },
exec: { mode: "deny", applyPatch: { workspaceOnly: true } },
elevated: { enabled: false },
codeMode: false,
},
}
run: |
set -euo pipefail
ctx="$RUNNER_TEMP/openclaw-image"
mkdir -p "$ctx"
jq -n --arg v "$OPENCLAW_VERSION" '{private: true, dependencies: {openclaw: $v}}' > "$ctx/package.json"
printf '%s' "$OPENCLAW_CONFIG" > "$ctx/openclaw.json5"
docker build -q -t upseam-openclaw --build-arg IMAGE --build-arg OPENCLAW_INTEGRITY -f - "$ctx" <<'DOCKERFILE'
ARG IMAGE
FROM $IMAGE
ARG OPENCLAW_INTEGRITY
WORKDIR /opt/openclaw
COPY package.json openclaw.json5 ./
RUN npm install --ignore-scripts --no-audit --no-fund --before=2026-09-24T00:00:00Z && [ "$(node -p 'require("./node_modules/.package-lock.json").packages["node_modules/openclaw"].integrity')" = "$OPENCLAW_INTEGRITY" ] && OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL=1 node node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs && test ! -e node_modules/openclaw/.openclaw-lifecycle-pending
ENV PATH=/opt/openclaw/node_modules/.bin:$PATH
DOCKERFILE
- name: Start the egress proxy
env:
PROXY_PY: |
import asyncio, os, time
ALLOW = set(os.environ["ALLOW"].split(","))
SLOTS = asyncio.Semaphore(32)
IDLE = 300
async def pipe(r, w, seen):
try:
while data := await r.read(65536):
seen[0] = time.monotonic()
w.write(data)
await w.drain()
w.close()
except OSError:
w.transport.abort()
async def handle(r, w):
if SLOTS.locked():
w.close()
return
async with SLOTS:
try:
head = await asyncio.wait_for(r.readuntil(b"\r\n\r\n"), 10)
method, target, _ = head.split(b"\r\n")[0].split(b" ")
host, port = target.decode("ascii").rsplit(":", 1)
if method != b"CONNECT" or port != "443" or host not in ALLOW:
raise PermissionError
ur, uw = await asyncio.wait_for(asyncio.open_connection(host, 443), 10)
except Exception:
print("deny", flush=True)
w.write(b"HTTP/1.1 403 Forbidden\r\n\r\n")
w.close()
return
print("allow", host, flush=True)
w.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
seen = [time.monotonic()]
tunnel = asyncio.gather(pipe(r, uw, seen), pipe(ur, w, seen))
while not tunnel.done():
await asyncio.wait([tunnel], timeout=10)
if time.monotonic() - seen[0] > IDLE:
uw.transport.abort()
w.transport.abort()
async def main():
server = await asyncio.start_server(handle, "0.0.0.0", 3128)
await server.serve_forever()
asyncio.run(main())
run: |
set -euo pipefail
docker network create --internal -o com.docker.network.bridge.gateway_mode_ipv4=isolated upseam-sandbox
docker network create upseam-egress
docker run -d --name upseam-proxy --network upseam-egress \
--read-only --cap-drop=ALL --security-opt no-new-privileges \
--user 65534:65534 --pids-limit 64 --memory 128m --memory-swap 128m \
-e ALLOW="$PROVIDER_HOST" \
"$PROXY_IMAGE" python -c "$PROXY_PY"
docker network connect upseam-sandbox upseam-proxy
- name: Run OpenClaw in the sandbox
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TASK: ${{ github.event.client_payload.task }}
run: |
set -euo pipefail
printf '%s' "$TASK" | docker run --rm -i --network upseam-sandbox \
--read-only --tmpfs /tmp:rw,nosuid,nodev,size=512m \
--cap-drop=ALL --security-opt no-new-privileges \
--user 65534:65534 --pids-limit 256 --memory 2g --memory-swap 2g \
-v "$RUNNER_TEMP/upseam-work:/work" -w /tmp \
-e HOME=/tmp -e CI=true -e DO_NOT_TRACK=1 \
-e OPENCLAW_NO_AUTO_UPDATE=1 -e OPENCLAW_TELEMETRY=0 \
-e HTTPS_PROXY=http://upseam-proxy:3128 \
-e HTTP_PROXY=http://upseam-proxy:3128 \
-e ANTHROPIC_API_KEY \
upseam-openclaw \
openclaw agent exec --config /opt/openclaw/openclaw.json5 \
--cwd /work --message-file - --model "$MODEL" --timeout 900 >/dev/null 2>&1 || status=$?
echo "Agent exit code: ${status:-0}"
exit "${status:-0}"
- name: Show the proxy log and remove the sandbox
if: always()
run: |
docker logs upseam-proxy || true
docker rm -f upseam-proxy || true
docker network rm upseam-sandbox upseam-egress || true
- name: Copy the edits into the checkout
run: |
set -euo pipefail
work="$(realpath -- "$RUNNER_TEMP/upseam-work")"
root="$(realpath -- .upseam-check)"
total=0
while IFS= read -r -d '' f; do
case "$f" in "" | /* | ../* | */../* | */.. | .git/* | */.git/*) echo "::error::Bad path in files."; exit 1 ;; esac
if [ -f "$work/$f" ] && [ "$(realpath -e -- "$work/$f")" = "$work/$f" ] && [ -f ".upseam-check/$f" ] && [ ! -L ".upseam-check/$f" ] && [ "$(realpath -e -- ".upseam-check/$f")" = "$root/$f" ]; then
total=$((total + $(stat -c %s -- "$work/$f")))
[ "$total" -le 1048576 ] || { echo "::error::The edits are over 1 MB."; exit 1; }
cp -- "$work/$f" ".upseam-check/$f"
fi
done < <(jq -j '.[] | ., "\u0000"' <<<"$FILES")
- uses: upseam/action@<40-character SHA> # v0.x, after the first release
with:
path: .upseam-check
check-only: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upseam-edits
path: ${{ runner.temp }}/upseam-edits
include-hidden-files: true
if-no-files-found: error
retention-days: 1What happens
Claude Code, Codex, OpenCode, Hermes Agent and OpenClaw are supported; the workflow runs once the first upseam/action release is out. Learn more
Trademarks belong to their owners; no endorsement implied.
06 / Join the beta
Free during the beta.
Install with your coding agent
One command sets Upseam up in your repository. Preview first; nothing is written until you apply. Read what it does
Claude Code. Ask Claude Code to run these in your repository. It shows the preview before anything is written.
Codex. Run them in Codex's terminal, or ask Codex to run them for you.
OpenCode. Ask OpenCode to run these from your repository root.
OpenClaw. Ask OpenClaw to run these in your repository's workspace.
Hermes. Ask Hermes to run these with its terminal tool in your repository.
npx @upseam/cli init --dry-run --json
npx @upseam/cli init --yes
Trademarks belong to their owners; no endorsement implied.