Spens logoSpens

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:dind service 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_TOKEN holding an access token with api scope. The job token cannot post merge request notes, so a project or group access token is required
  • A .spens.config.json committed 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: envsubst is not part of a minimal shell image. On Debian-based images install the gettext package (apt-get install gettext); on Alpine-based images (e.g. docker:27) it is also gettext, via apk 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 of envsubst: not found. Guard against it with test -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.md

How it fits together

  • Installing spens happens in before_script, so the job is self-contained: it installs the spens CLI plus the Docker client it drives, and the docker:dind service provides the daemon. If your runner has the Docker socket bind-mounted instead, drop the services block and the DOCKER_HOST/DOCKER_TLS_CERTDIR variables.
  • The prompt comes from the file, parsed by envsubst, which replaces every $VAR in 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 jsonl streams the session events into the job log, and it requires exactly one of --accept-changes / --reject-changes so the end-of-session change review cannot block a non-interactive run.
  • --accept-changes keeps the review file. The agent writes code-review.md as a workspace change; with --reject-changes the workspace would roll back to its pre-session state and the file would vanish with it.
  • The comment is posted by plain curl against 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; the artifacts block 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.

On this page