Spens in CI/CD
Run a sandboxed agent code review on every GitLab merge request
This example runs spens in a GitLab CI pipeline: every merge request gets a code review written by an agent inside a full spens sandbox. The agent reads the MR changes, writes its findings to a file, and the pipeline posts that file as a comment on the merge request.
The goal here is the wiring, not the prompt — the review prompt is deliberately simple.
What you need
- A GitLab runner that can run Docker: either a runner that allows privileged services (for the
docker:dindservice used below) or a runner with the Docker socket bind-mounted - A masked CI/CD variable holding your model provider key (here
ANTHROPIC_API_KEY) - A masked CI/CD variable
SPENS_REVIEW_TOKENholding an access token withapiscope. The job token cannot post merge request notes, so a project or group access token is required - A
.spens.config.jsoncommitted to the repository
Warning: Due to limitations in hosted gitlab runners, this will require a self hosted runner.
1. The spens config
A code review agent needs exactly one piece of network access: the model API. Generate a minimal config and commit it:
{
"domain_rules": [
{"pattern": "*api.anthropic.com*", "allow": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]}
],
"inject_headers": [
{
"placeholder": "ANTHROPIC_API_KEY",
"env_var": "ANTHROPIC_API_KEY",
"for_domains": ["*api.anthropic.com*"]
}
]
}Everything else fails closed. The agent can call the model, and nothing else — no registries, no git remotes, no internal services. The real API key lives only in the CI environment and the interceptor; the agent container gets a placeholder.
2. The prompt file
Create prompts/code-review.md in the repository:
Review the merge request "${CI_MERGE_REQUEST_TITLE}" in ${CI_PROJECT_PATH}.
The working tree is the merge result, and the changes start at
${CI_MERGE_REQUEST_DIFF_BASE_SHA}. Run
`git diff ${CI_MERGE_REQUEST_DIFF_BASE_SHA}...HEAD` to see them.
Write a short code review to `code-review.md`:
- Real problems only: bugs, security issues, broken logic. One line each,
with the file path.
- If nothing is wrong, write "No issues found."
Do not modify any file other than `code-review.md`.The ${...} placeholders are GitLab predefined variables. They are substituted when the job runs (next step), so the prompt file stays static in the repo while each pipeline gets a prompt that names the actual MR.
Warning:
envsubstis not part of a minimal shell image. On Debian-based images install thegettextpackage (apt-get install gettext); on Alpine-based images (e.g.docker:27) it is alsogettext, viaapk add gettext. If it is missing, the$(...)substitution swallows the failure and exports an empty prompt — the job then dies later with a confusing--output jsonl requires a prompt argument (yolo mode)error instead ofenvsubst: not found. Guard against it withtest -n "$SPENS_PROMPT"right after the export.
3. The pipeline job
Add a job to .gitlab-ci.yml:
stages:
- review
code-review:
stage: review
image: python:3.12-slim
services:
- name: docker:dind
alias: docker
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
# full history so the prompt's `git diff <base>...HEAD` works
GIT_DEPTH: "0"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
before_script:
# spens drives the Docker CLI; the dind service provides the daemon
- apt-get update && apt-get install -y --no-install-recommends docker.io gettext curl
- pip install spens-ai
script:
# substitute the GitLab variables into the prompt file
- export SPENS_PROMPT="$(envsubst < prompts/code-review.md)"
# run the agent unattended; its only network access is the model API
- spens node-24 pi . "$SPENS_PROMPT" --output jsonl --accept-changes --session-id "mr-$CI_MERGE_REQUEST_IID"
# post the review as a comment on the merge request
- |
if [ -f code-review.md ]; then
curl -sS --fail --request POST \
--header "PRIVATE-TOKEN: $SPENS_REVIEW_TOKEN" \
--data-urlencode "body@code-review.md" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes"
else
echo "agent did not write code-review.md" && exit 1
fi
artifacts:
when: always
paths:
- code-review.mdHow it fits together
- Installing spens happens in
before_script, so the job is self-contained: it installs thespensCLI plus the Docker client it drives, and thedocker:dindservice provides the daemon. If your runner has the Docker socket bind-mounted instead, drop theservicesblock and theDOCKER_HOST/DOCKER_TLS_CERTDIRvariables. - The prompt comes from the file, parsed by
envsubst, which replaces every$VARin it. Keep stray dollar signs out of the file: anything that is not a defined GitLab variable becomes an empty string. - Yolo mode is triggered by passing a prompt as the fourth argument; the pi agent template turns it into
pi -p <prompt>.--output jsonlstreams the session events into the job log, and it requires exactly one of--accept-changes/--reject-changesso the end-of-session change review cannot block a non-interactive run. --accept-changeskeeps the review file. The agent writescode-review.mdas a workspace change; with--reject-changesthe workspace would roll back to its pre-session state and the file would vanish with it.- The comment is posted by plain
curlagainst the GitLab notes API, with the review file as the body. The agent never talks to GitLab: it has no token and no network route to it. - Session state lands in
.spens/inside the job workspace and disappears with it; theartifactsblock keeps the review itself.
The first run on a fresh runner builds the environment, agent, and interceptor images from scratch, which takes a few minutes. A dind service starts with an empty Docker cache every job, so if that is too slow, switch to a socket-mounted runner with a persistent image cache, or pre-build the images and push them to your registry.
Spens