# Claude Code

> Cordium documentation. Canonical page: <https://octelium.com/docs/cordium/latest/examples/ai/claude-code>.

This example runs [Claude Code](https://code.claude.com/docs) unattended inside ephemeral Cordium *Workspaces* to fix bugs and implement small features in a Go repository. Each run clones the repository, lets Claude Code make and test the change, pushes a branch and opens a draft pull request, and then stops and discards the *Workspace*.

What makes this setup different from running an agent in a typical container or CI runner is that the *Workspace* holds no credential at all: not the Anthropic API key, not a GitHub token, nothing. Claude Code talks to the Anthropic API, `git` pushes the branch and `curl` opens the pull request through Octelium *Services* that inject the credentials on a per-request basis and only for the *Workspaces* of a specific *Template* and repository (read more about secretless access [here](https://octelium.com/docs/cordium/latest/workspaces/secretless.md)). A prompt-injected or misbehaving agent therefore has nothing to exfiltrate, and every request it makes is authorized and logged by Octelium.

## The Octelium Services

First, a *Cluster* administrator stores the credentials as Octelium *Secrets*. The GitHub token is a fine-grained personal access token or a GitHub App installation token that has the **Contents** and **Pull requests** read and write permissions on the target repository only:

```bash
octeliumctl create secret anthropic-api-key
octeliumctl create secret github-agent-token
```

Then, the administrator creates the following *Services*:

- `anthropic`: an `LLM` *Service* that exposes the Anthropic API (read more about LLM *Services* [here](https://octelium.com/docs/octelium/latest/management/core/service/llm/overview.md)).
- `github.payments`: an `HTTP` *Service* in front of `https://github.com` that injects the token as HTTP basic authentication, which is what `git` uses over HTTPS.
- `github-api.payments`: an `HTTP` *Service* in front of the GitHub REST API that injects the token as a bearer token.

Both GitHub *Services* are restricted to the *Workspaces* of the `claude-agent` *Template* of the `payments` *Space*, and to the `acme-corp/payments-api` repository:

```yaml
kind: Service
metadata:
  name: anthropic
spec:
  mode: LLM
  config:
    upstream:
      url: https://api.anthropic.com
    llm:
      protocol: ANTHROPIC
      auth:
        custom:
          header: x-api-key
          value:
            fromSecret: anthropic-api-key
---
kind: Service
metadata:
  name: github.payments
spec:
  mode: HTTP
  config:
    upstream:
      url: https://github.com
    http:
      auth:
        basic:
          username: x-access-token
          password:
            fromSecret: github-agent-token
  authorization:
    inlinePolicies:
      - spec:
          rules:
            - effect: ALLOW
              condition:
                all:
                  of:
                    - match: '"ext" in ctx.session.status && "cordium" in ctx.session.status.ext'
                    - match: ctx.session.status.ext.cordium.templateRef.name == "claude-agent.payments.cordium"
                    - match: ctx.request.http.path.startsWith("/acme-corp/payments-api.git/")
---
kind: Service
metadata:
  name: github-api.payments
spec:
  mode: HTTP
  config:
    upstream:
      url: https://api.github.com
    http:
      auth:
        bearer:
          fromSecret: github-agent-token
  authorization:
    inlinePolicies:
      - spec:
          rules:
            - effect: ALLOW
              condition:
                all:
                  of:
                    - match: '"ext" in ctx.session.status && "cordium" in ctx.session.status.ext'
                    - match: ctx.session.status.ext.cordium.templateRef.name == "claude-agent.payments.cordium"
                    - match: ctx.request.http.path.startsWith("/repos/acme-corp/payments-api/")
```

You can apply them via `octeliumctl apply` (read more [here](https://octelium.com/docs/octelium/latest/management/core/overview.md)). The *Users* who run the agent must also be allowed to access the `anthropic` *Service* by a *Policy* (read more about scoping LLM access [here](https://octelium.com/docs/cordium/latest/examples/ai/secretless-llm.md)).

> **Note:**
>
> The inline *Policies* above make the GitHub *Services* usable only from the agent's *Workspaces* and only for one repository. Even the *Users* who own those *Workspaces* cannot use the token from their laptops, and the agent cannot push to any other repository or call any other GitHub API.

## The Template

Next, a *Space* admin creates the following *Template* in the `payments` *Space*. Its `ON_CREATE` tasks install Claude Code and a few tools, and its `agent` task runs the whole workflow once the *Workspace* is connected to the *Cluster*:

```yaml
spec:
  image:
    registry:
      url: golang:1.26-bookworm
  vars:
    - name: REPO
      value: acme-corp/payments-api
    - name: BASE
      value: main
    - name: MODEL
      value: claude-opus-5-5
    - name: TASK
      value: ""
  runtime:
    autoStop: true
    envVars:
      - key: ANTHROPIC_BASE_URL
        value: http://anthropic
      # A placeholder. Octelium injects the real API key and never forwards this one.
      - key: ANTHROPIC_API_KEY
        value: injected-by-octelium
    tasks:
      - name: install-packages
        type: ON_CREATE
        runAsRoot: true
        onFailure: ON_FAILURE_ABORT
        run: |
          apt-get update
          apt-get install -y --no-install-recommends jq ripgrep
      - name: install-claude-code
        type: ON_CREATE
        workingDir: /workspace
        onFailure: ON_FAILURE_ABORT
        run: curl -fsSL https://claude.ai/install.sh | bash
      - name: agent
        type: POST_START
        workingDir: /workspace
        onFailure: ON_FAILURE_ABORT
        envVars:
          - key: REPO
            value: ${{ vars.REPO }}
          - key: BASE
            value: ${{ vars.BASE }}
          - key: MODEL
            value: ${{ vars.MODEL }}
          - key: TASK
            value: ${{ vars.TASK }}
        run: |
          set -eu
          [ -n "$TASK" ] || exit 0
          export PATH="$HOME/.local/bin:$PATH"

          timeout 120 sh -c 'until curl -s -o /dev/null http://github.payments/; do sleep 2; done'

          git clone --branch "$BASE" "http://github.payments/$REPO.git" repo
          cd repo
          BRANCH="agent/$CORDIUM_NAME"
          git switch -c "$BRANCH"

          cat > /tmp/prompt.md <<EOF
          $TASK

          Work only inside this repository. Run "go vet ./..." and the relevant
          "go test" packages, and make sure they pass before you finish. Do not
          commit or push. End with a concise pull request description in Markdown.
          EOF

          claude -p "$(cat /tmp/prompt.md)" \
            --model "$MODEL" \
            --dangerously-skip-permissions \
            --output-format text < /dev/null > /tmp/summary.md

          if [ -z "$(git status --porcelain)" ]; then
            echo "Claude Code made no changes"
            exit 0
          fi

          TITLE="$(printf '%s\n' "$TASK" | head -n 1 | cut -c1-72)"
          git add -A
          git -c user.name="Claude Code" -c user.email="agents@acme-corp.example" \
            commit -q -m "$TITLE"
          git push -q origin "$BRANCH"

          jq -n --arg title "$TITLE" --arg head "$BRANCH" --arg base "$BASE" \
            --rawfile body /tmp/summary.md \
            '{title: $title, head: $head, base: $base, body: $body, draft: true}' |
            curl -sSf -X POST "http://github-api.payments/repos/$REPO/pulls" \
              -H "Accept: application/vnd.github+json" --data @- |
            jq -r '"Opened " + .html_url'
  limit:
    cpu:
      millicores: 4000
    memory:
      megabytes: 8192
    storage:
      megabytes: 20000
```

Here are a few notes about this *Template*:

- The `agent` task is a `POST_START` task since it needs the *Workspace*'s connection to the *Cluster*, which is established at the beginning of the `POST_START` phase. It waits for `github.payments` to become reachable before cloning (read more [here](https://octelium.com/docs/cordium/latest/workspaces/runtime.md#task-order)).
- The task variables are passed to the script as environment variables rather than substituted into the script itself, which keeps an arbitrary `TASK` text from being interpreted by the shell.
- Claude Code's standard input is redirected from `/dev/null`, since `claude -p` otherwise waits for piped input when its standard input is not a terminal.
- `--dangerously-skip-permissions` lets Claude Code run commands and edit files without prompting. This is what the *Workspace* is for: an isolated, disposable sandbox in which the agent runs as an unprivileged user and holds no credentials. You can further restrict its network egress (read more [here](https://octelium.com/docs/cordium/latest/examples/security/untrusted-code.md)).
- `autoStop` stops the *Workspace* as soon as the `agent` task completes, and the `ON_FAILURE_ABORT` policy makes the run fail if any step fails. All the foreground tasks of a run must complete within 60 minutes (read more [here](https://octelium.com/docs/cordium/latest/workspaces/runtime.md#timeouts-and-failures)).

Create the *Template* and pre-build it so that every run starts with the tools already installed (read more about pre-builds [here](https://octelium.com/docs/cordium/latest/workspaces/templates.md#pre-builds)):

```bash
cordium create template claude-agent.payments.cordium --file claude-agent.yaml
cordium build claude-agent.payments.cordium
```

> **Note:**
>
> Pre-builds run the foreground `POST_START` tasks too, but without any connection to the *Cluster*. This is why the `agent` task exits immediately when `TASK` is empty, which is its default value.

## Running the Agent

Every run is a new ephemeral *Workspace* of the *Template* with its own `TASK`:

```bash
cordium create ws --template claude-agent.payments.cordium --ephemeral --start \
  --var TASK="Fix #1842: TestWebhookRetry is flaky because the retry backoff reads the wall clock. Use the injected clock instead."
```

You can follow the run live from the web portal or via `cordium logs`:

```bash
cordium logs <WORKSPACE>
```

```text
Cloning into 'repo'...
Switched to a new branch 'agent/q8zr2'
Opened https://github.com/acme-corp/payments-api/pull/1851
```

You can also trigger runs programmatically, for example from an issue tracker webhook or a scheduler, via the SDKs. Here is an example via the Python SDK that starts a run and waits until it stops:

```python
from cordium import Cordium, WorkspaceFailureError

with Cordium() as client:
    workspace = client.workspaces.create(
        template="claude-agent.payments.cordium",
        ephemeral=True,
        vars={"TASK": "Fix #1842: TestWebhookRetry is flaky. Use the injected clock."},
    )
    workspace.start()
    try:
        workspace.wait_until_stopped(timeout=3600)
        print(f"Workspace {workspace.name} completed")
    except WorkspaceFailureError as error:
        print(f"Workspace {workspace.name} failed: {error}")
    finally:
        workspace.delete()
```

## Interactive Sessions

The same *Template* also works for interactive sessions. Create a persistent *Workspace* without a `TASK`, open a terminal from the web portal, or via `cordium ssh` or VS Code, and run `claude` as usual. Since `ANTHROPIC_BASE_URL` points to the `anthropic` *Service*, Claude Code uses the *Cluster*'s Anthropic API key without any login:

```bash
cordium create ws --template claude-agent.payments.cordium --start
cordium ssh <WORKSPACE>
```

```bash
cd /workspace && git clone http://github.payments/acme-corp/payments-api.git && cd payments-api
claude
```

> **Note:**
>
> If your team uses Claude subscriptions instead of API keys, store a token generated via `claude setup-token` as a *UserSecret* and inject it via your *UserConfig* as the `CLAUDE_CODE_OAUTH_TOKEN` environment variable (read more [here](https://octelium.com/docs/cordium/latest/workspaces/user-config.md)). In that case, remove `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` from the *Template*. Note, however, that the token is then readable inside the *Workspace*.
