Build a private repository cleanup agent

Build a coding agent that improves code in a private GitHub repository and opens a pull request. You define the agent in one project and call it from a separate client project.

The agent works in a fresh managed sandbox. Sandbox Proxy lets the sandbox use your GitHub connector without copying the GitHub credential into the sandbox. The agent uses the mistral-large-4 model.

An optional final section shows how to place a custom scheduled workflow on top of the deployed agent. You do not need the scheduler to build, test, or deploy the agent.

Demonstrates: sandboxes and Sandbox Proxy, a timeout on each step, idempotency keys, ending a session, serving and deploying an agent.

Run (after the setup below), from the example's client/ folder: uv run --frozen --env-file .env python run_agent.py (setup).

How it works

How it works

The client creates a session against the agent and sends the cleanup task. The agent clones the repository, creates a branch, makes coherent code cleanups, runs focused checks, and opens a pull request.

client/ → agent/ → MistralSandbox → Sandbox Proxy → private GitHub repository
                                                        ↓
                                                  pull request

The example keeps each responsibility in a separate folder:

FolderPurpose
agent/The Agent() definition, instructions, Dockerfile, and agent dependencies
client/The cleanup job that opens a session, sends the cleanup task, and consumes the result; optionally run on a schedule by Mistral Workflows
1. Open the example

1. Open the example

The example's folder has this structure:

private_repository_cleanup_agent/
├── README.md
├── agent/
│   ├── Dockerfile
│   ├── instructions.md
│   ├── main.py
│   └── pyproject.toml
└── client/
    ├── cleanup_task.py
    ├── run_agent.py
    ├── scheduled_review.py
    ├── worker.py
    ├── Dockerfile
    └── pyproject.toml
2. Prepare your credentials

2. Prepare your credentials

You need Python 3.12, uv, and a MISTRAL_API_KEY. The key authenticates the local agent and client, and it also creates the managed sandbox. To serve or deploy the agent, the key's user needs a workspace role with Workflows access, such as Developer: see Deploy. For the sandbox, your Mistral identity also needs the cloud_user workspace role (Mistral Cloud User in the console). See Requirements to get it.

In Mistral Studio, enable the GitHub connector. The connected GitHub account must be able to clone the repository, push a branch, and open a pull request.

Set up the agent project:

cd agent
cp .env.example .env

Add your API key to agent/.env:

MISTRAL_API_KEY=...
3. Configure the repository

3. Configure the repository

Open agent/main.py and replace your-org/your-private-repo with your private repository:

sandbox = agents.MistralSandbox(
    network=agents.Network.all(),
    header_bindings=(
        # Sandbox Proxy gets a token from the GitHub access of the user who starts the session.
        agents.GitHubBinding(repos=("your-org/your-private-repo",)),
    ),
)

Sandbox Proxy gets a short-lived token from your GitHub connector and adds it to the sandbox's GitHub traffic. repos limits Sandbox Proxy to this repository. It does not grant GitHub permissions. The GitHub identity connected in Mistral Studio still needs permission to create branches and pull requests.

Make the same repository replacement in client/cleanup_task.py. The client includes the repository in the task prompt and validates the pull request URL returned by the agent.

4. Define the agent

4. Define the agent

The agent definition stays in agent/main.py:

agent = agents.Agent(
    model="mistral-large-4",
    name=AGENT_NAME,
    instructions=(PROJECT_DIR / "instructions.md").read_text(encoding="utf-8"),
    environment=sandbox,
    max_iterations=60,
    temperature=0.1,
)

The agent/pyproject.toml pins the SDK with the koyeb extra, which the managed sandbox needs.

The instructions tell the agent to read the repository guidance before editing. They also require the agent to make code changes, run focused checks, create a timestamped branch, and open a pull request. They prohibit dependency updates, migrations, generated files, authentication changes, deployment changes, and documentation-only pull requests.

Install the agent project and inspect its configuration:

uv sync
uv run --frozen --env-file .env agents info
5. Serve the agent locally

5. Serve the agent locally

From agent/, start the agent and leave it running:

uv run --frozen --env-file .env agents serve

The agent project contains no client or scheduling code. Its Dockerfile, which agents deploy builds, also starts only the agent.

6. Start and consume a session

6. Start and consume a session

Open another terminal, go to the example's client/ folder, and configure the client project:

cp .env.example .env
uv sync

Set MISTRAL_API_KEY and keep AGENT_NAME equal to the name in agent/main.py:

MISTRAL_API_KEY=...
AGENT_NAME=private-repository-cleanup-agent

The client resolves the agent by name and runs the cleanup job in client/cleanup_task.py. The scheduled workflow below runs the same function:

async def run_cleanup(agent: agents.Agent, job: str) -> str:
    """Run the cleanup in one session until the agent returns a pull request URL.

    `job` names this run. The session start and each message carry an idempotency key made from
    it, so running the same job again after a crash continues the same session and turns, and
    sends nothing twice.
    """
    session = await agent.start_session(idempotency_key=job)
    try:
        prompt = cleanup_prompt()
        replies = []
        for step in range(MAX_TURNS):
            # timeout= stops the wait, not the turn: a turn still running then comes back as Running.
            match await session.run(prompt, idempotency_key=f"{job}:{step}", timeout=TURN_TIMEOUT_SECONDS):
                case agents.Completed(text=reply):
                    replies.append(reply or "")
                    pull_request_url = extract_pull_request_url(reply or "")
                    if pull_request_url is not None:
                        return pull_request_url
                    prompt = CONTINUE_PROMPT
                case agents.Running():
                    raise RuntimeError(f"A turn ran for more than {TURN_TIMEOUT_SECONDS // 60} minutes.")
                case agents.Blocked(callbacks=callbacks):
                    # The agent asks for no approval, so a callback here needs a person.
                    raise RuntimeError(f"The agent waits for a person: {[callback.kind for callback in callbacks]}")
                case agents.Failed(error=error):
                    raise RuntimeError(f"The agent turn failed: {error.message}")
                case state:
                    raise RuntimeError(f"The agent turn did not complete: {state!r}")

        raise RuntimeError(
            f"The agent used {MAX_TURNS} turns without returning a pull request URL. "
            f"Its replies were:\n{'\n\n'.join(replies)}"
        )
    finally:
        # The job owns its session: end it, which also stops a turn that is still running.
        await session.cancel()

session.run() waits until the turn ends, or for up to 45 minutes: timeout= stops the wait, not the turn, so a turn still running comes back as Running. If the agent needs another turn to finish the branch or pull request, the job continues in the same session and sandbox. The job owns its session, so it ends it when it is done, which also stops a turn that is still running.

The session start and each message carry an idempotency key made from the job id that run_agent.py prints. An error or Ctrl-C ends the session. If the process stops before it can, for example when it is killed or the machine restarts, python run_agent.py --job <id> continues the same session and sends nothing twice.

Run the client:

uv run --frozen --env-file .env python run_agent.py

The command finishes by printing a pull request URL:

Output
https://github.com/your-org/your-private-repo/pull/123

Each run creates a new agent-cleanup/<UTC timestamp>-<short-name> branch. An earlier cleanup pull request can remain open without blocking the next run.

7. Deploy the agent

7. Deploy the agent

Commit and push agent/ first: the platform builds the pushed commit. Then deploy from the agent/ directory:

cd ../agent
uv run --frozen --env-file .env agents deploy . \
  --name private-repository-cleanup-agent \
  --hardened

The deployed agent creates the sandbox with its own service account. The deploy gives that service account the cloud_user role, so you must have the role yourself.

Keep --hardened. Sandbox Proxy uses the identity of the user who starts the agent session. That user must have the GitHub connector configured in Mistral Studio.

The client does not change when the agent is deployed. Set AGENT_NAME in client/.env to the deployed name and run client/run_agent.py again.

Optional: add a scheduled workflow

Optional: add a scheduled workflow

You can run the same cleanup job on a schedule from a separate Workflows deployment.

Run the cleanup on a schedule

client/scheduled_review.py and client/worker.py are a Workflows worker that runs the same cleanup job. They are not loaded by the agent and they are not included in the agent deployment.

A scheduled execution runs as its worker's service account, not as the user who created the schedule. So this optional project stores a user API key in a workspace secret named SCHEDULED_AGENT_API_KEY. Its activity uses that key to open a separate session against the hardened agent, and Sandbox Proxy uses that user's GitHub connector. Each attempt runs the same job:

# Up to four turns of up to 45 minutes each. A retry runs the same job: after a worker crash, noticed
# once start_to_close_timeout has passed, it continues the same session and turns instead of
# starting a second cleanup; after an error, which ended the session, it fails at once.
@workflows.activity(
    start_to_close_timeout=timedelta(hours=3, minutes=10),
    retry_policy_max_attempts=3,
)
async def run_scheduled_cleanup(job: str) -> str:
    import os  # noqa: PLC0415

    from cleanup_task import AGENT_NAME, run_cleanup  # noqa: PLC0415

    from mistralai import agents  # noqa: PLC0415

    api_key = os.environ.get(SCHEDULED_AGENT_API_KEY_ENV_VAR, "").strip()
    if not api_key:
        raise RuntimeError(
            f"Bind a user API key to {SCHEDULED_AGENT_API_KEY_ENV_VAR}. "
            "The user must have access to the configured GitHub connector."
        )

    deployed_agent = agents.Agent.load_from(
        agent_name=AGENT_NAME,
        api_key=api_key,
    )
    return await run_cleanup(deployed_agent, job)

The GitHub credential remains in the connector service. The user API key authenticates the new session as the user who owns that connector.

Each scheduled run is one job. The activity may retry: after a worker crash, which Workflows notices once the activity's time limit has passed, the retry runs the same job, so it continues the same session and turns instead of starting a second cleanup. An error ends the session, so a retry after an error fails at once.

To use the optional scheduler:

  1. Replace your-org/your-private-repo in client/cleanup_task.py, if not done already.
  2. Create a workspace secret containing a user API key whose user has the GitHub connector.
  3. Create a managed Workflows deployment from the client/ directory.
  4. Bind the workspace secret to SCHEDULED_AGENT_API_KEY.
  5. In Workflows, schedule private-repository-cleanup-schedule with the input {} and a cron expression, such as 0 0 * * * (every day at midnight in the time zone selected in Mistral Studio).

The agent and scheduler remain separate managed deployments.

Troubleshooting

Troubleshooting

MistralSandbox requires koyeb-sdk

MistralSandbox requires koyeb-sdk

Run uv sync from agent/, and keep the koyeb extra on mistralai-agents in its pyproject.toml: the managed sandbox needs it.

Mistral Cloud refused to create the sandbox (HTTP 401) or (HTTP 403)

Mistral Cloud refused to create the sandbox (HTTP 401) or (HTTP 403)

Your Mistral identity does not have the cloud_user workspace role (Mistral Cloud User in the console). Get it as described in Requirements, then start the session again. For a deployed agent, deploy it again after you get the role.

The sandbox deployment failed

The sandbox deployment failed

The sandbox did not start. The session's error says why. If you set a private image, see Image and setup for the registry secret it needs.

SandboxProxyInitialSetupError

SandboxProxyInitialSetupError

Check that the GitHub connector is enabled for the Mistral identity associated with the session. Confirm that the connected GitHub account can access the repository in repos.

The agent can clone but cannot push

The agent can clone but cannot push

The connected GitHub account has read access but not write access, or an organization policy blocks the branch or pull request. Give the account the required repository role or use a repository where it may create branches and pull requests.

The client cannot find the agent

The client cannot find the agent

Confirm that AGENT_NAME in client/.env matches the name in agent/main.py when serving locally, or the name passed to agents deploy when using a managed deployment.

Resources and next steps

Resources and next steps

  • Sandbox explains managed sandboxes and Sandbox Proxy.
  • What the agent can use shows how tools and sandboxes are attached to an agent.
  • Run modes compares running the agent in your process with deploying it with Studio (self-hosted or Mistral-managed).