> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.lakera.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.lakera.ai/_mcp/server.

# Run Scans in CI/CD

Run a scan from your pipeline to test each version of your agent before it ships. The
SDK makes only outbound HTTPS requests, so it runs on ordinary CI runners with no
inbound access (see [Deployment](/docs/red/sdk-deployment) for proxy and CA details).

This page covers the gate script and the pipeline wiring. For SDK installation, client
setup, and session handling, see the [Quickstart](/docs/red/sdk-quickstart). Provide the
API key through your CI provider's secret store as the `LAKERA_RED_API_KEY` environment
variable; never hardcode or commit it.

## The gate script

The script has two parts: a **handler** that relays each attack to your agent — this
part is always yours to own — and **gate logic** that runs the scan and decides
pass/fail, which is the same for every project and can be reused as is. For a complete
handler wired to a real agent, see the
[full example in the Quickstart](/docs/red/sdk-quickstart#full-example).

Your script decides what blocks the release: it inspects the per-objective results and
sets the exit code. The script below gates on `isSuccessful` — the same pass/fail the
dashboard shows, computed server-side at the platform's default threshold. If your risk
bar is stricter or looser, see [Custom pass/fail criteria](#custom-passfail-criteria).

Each `ScanResultEntry` has an `objectiveId`, the `conversation`, an `evaluation`
(`attackSuccessScore` 0–5, `attackSuccessIndicator`, `explanation`), the `isSuccessful`
verdict, and an `error` if the probe failed. The gate also fails closed: errored,
unscored, and never-ready results block the release.

#### TypeScript

```typescript
// red-gate.ts — run with: npx tsx red-gate.ts
import { writeFileSync } from "node:fs"

import { LakeraRedClient } from "lakera-red-sdk"

const client = new LakeraRedClient({
  apiKey: process.env.LAKERA_RED_API_KEY!,
  baseUrl: "https://red-webhooks.lakera.ai",
})

// Part 1: the handler — replace myAgent.chat with however you call your agent.
const handler = async (session) => {
  for await (const { attack, respond } of session) {
    const reply = await myAgent.chat(attack)
    await respond(reply)
  }
}

// Part 2: the gate — generic; reuse as is.
async function main() {
  // Reused by name across runs; recon only runs the first time.
  const target = await client.createOrGetTarget({ name: "my-agent" }, handler)

  const scan = await client.createScan({
    name: `release gate ${process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? "local"}`,
    targetId: target.targetId,
    strategy: { name: "static", numberOfProbes: 5 },
    objectives: ["security.system-prompt-extraction.1"],
    concurrency: 3,
  })

  await scan.run(handler)

  // Results become ready only once the scan reaches a terminal state; poll.
  let results = await scan.getResults()
  for (let i = 0; !results.ready && i < 30; i++) {
    await new Promise((resolve) => setTimeout(resolve, 10_000))
    results = await scan.getResults()
  }

  // One snapshot feeds both the artifact and the gate, so they can't disagree.
  writeFileSync("./red-scan-results.json", JSON.stringify(results, null, 2))

  if (!results.ready || !results.results || results.results.length === 0) {
    console.error("gate: results not ready or empty — failing closed")
    process.exit(1)
  }

  const flagged = results.results.filter((r) => r.isSuccessful)
  const errored = results.results.filter((r) => r.error)
  const unscored = results.results.filter(
    (r) => !r.error && r.evaluation?.attackSuccessScore === undefined,
  )

  console.log(`full report: ${scan.dashboardLink}`)
  for (const r of flagged) console.error(`flagged: ${r.objectiveId}`)
  for (const r of errored) console.error(`errored: ${r.objectiveId}`)

  const failedSafetyCheck =
    flagged.length > 0 || errored.length > 0 || unscored.length > 0
  if (failedSafetyCheck) {
    process.exit(1)
  }
  console.log("gate: pass")
}

main().catch((error) => {
  console.error(error)
  process.exit(1)
})
```

#### Python

```python
# red_gate.py — run with: python red_gate.py
import asyncio
import json
import os
import sys

from lakera_red_sdk import LakeraRedClient, StaticStrategyOptions


# Part 1: the handler — replace my_agent.chat with however you call your agent.
async def handler(session):
    async for message in session:
        reply = await my_agent.chat(message.attack)
        await message.respond(reply)


# Part 2: the gate — generic; reuse as is.
async def main():
    async with LakeraRedClient(
        api_key=os.environ["LAKERA_RED_API_KEY"],
        base_url="https://red-webhooks.lakera.ai",
    ) as client:
        # Reused by name across runs; recon only runs the first time.
        target = await client.create_or_get_target("my-agent", handler=handler)

        sha = os.environ.get("GITHUB_SHA") or os.environ.get("CI_COMMIT_SHA") or "local"
        scan = await client.create_scan(
            target.target_id,
            name=f"release gate {sha}",
            strategy=StaticStrategyOptions(number_of_probes=5),
            objectives=["security.system-prompt-extraction.1"],
            concurrency=3,
        )

        await scan.run(handler)

        # Results become ready only once the scan reaches a terminal state; poll.
        results = await scan.get_results()
        for _ in range(30):
            if results.ready:
                break
            await asyncio.sleep(10)
            results = await scan.get_results()

        # One snapshot feeds both the artifact and the gate, so they can't disagree.
        with open("red-scan-results.json", "w") as f:
            json.dump(results.to_json(), f, indent=2)

        if not results.ready or not results.results:
            print("gate: results not ready or empty — failing closed", file=sys.stderr)
            sys.exit(1)

        flagged = [r for r in results.results if r.is_successful]
        errored = [r for r in results.results if r.error]
        unscored = [
            r
            for r in results.results
            if not r.error
            and (r.evaluation is None or r.evaluation.attack_success_score is None)
        ]

        print(f"full report: {scan.dashboard_link}")
        for r in flagged:
            print(f"flagged: {r.objective_id}", file=sys.stderr)
        for r in errored:
            print(f"errored: {r.objective_id}", file=sys.stderr)

        if flagged or errored or unscored:
            sys.exit(1)
        print("gate: pass")


asyncio.run(main())
```

### Custom pass/fail criteria

Most teams keep the platform default. If your risk bar differs, gate on
`evaluation.attackSuccessScore` (0 = no success, 5 = full success) instead of
`isSuccessful`; the rest of the script stays the same. This example blocks a
customer-facing agent on any partial success:

#### TypeScript

```typescript
const flagged = results.results.filter(
  (r) => (r.evaluation?.attackSuccessScore ?? 0) >= 2,
)
```

#### Python

```python
flagged = [
    r
    for r in results.results
    if r.evaluation is not None and (r.evaluation.attack_success_score or 0) >= 2
]
```

Evaluation fields can be absent on errored or unscorable probes, so keep the `errored`
and `unscored` fail-closed checks from the script above.

## Call it from the pipeline

Add the gate where you already block deploys — typically a required job on the release
branch. Both jobs read the API key from CI secrets and upload the results JSON even when
the gate fails, so a blocked release can be reviewed. They assume `lakera-red-sdk` and
`tsx` are in your `package.json`. For Python, replace the Node steps with
`pip install lakera-red-sdk` and `python red_gate.py`.

GitHub Actions — store the key as a repository secret named `LAKERA_RED_API_KEY`:

```yaml
on: push

permissions:
  contents: read

jobs:
  red-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx tsx red-gate.ts
        env:
          LAKERA_RED_API_KEY: ${{ secrets.LAKERA_RED_API_KEY }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: red-scan-results
          path: red-scan-results.json
```

GitLab CI — define `LAKERA_RED_API_KEY` as a masked CI/CD variable; it is injected into
the job automatically:

```yaml
red-gate:
  image: node:22
  script:
    - npm ci
    - npx tsx red-gate.ts
  artifacts:
    when: always
    paths:
      - red-scan-results.json
```

## Best practices

1. **Match scan depth to pipeline stage** — run a fast `static` scan with a small
   `numberOfProbes` on every build, and a deeper adaptive scan (see
   [Strategies](/docs/red/sdk-reference#strategies)) before a release.
2. **Reuse one target per agent** — `createOrGetTarget` with a stable name keeps every
   run scanning the same target, so results stay comparable across builds. See
   [Reuse a Target Across Scans](/docs/red/sdk-how-tos/reuse-a-target).
3. **Scope objectives to your application** — start with a few objectives that map to
   real risks for the app, including
   [custom objectives](/docs/red/sdk-reference#customobjective), and expand as the gate
   earns trust; running everything on every build mostly adds noise and minutes.
4. **Store the results file as an artifact** — the gate writes the exact snapshot it
   decided on, giving each run a machine-readable record you can diff across builds and
   attach to the release. (`writeResults` also works when you just want the server's
   latest copy.)
5. **Gate on the results, review on the dashboard** — the pipeline decides pass/fail
   from the results JSON; `scan.dashboardLink` is the human review path for the full
   conversations behind a blocked release.

See [Creating a Scan](/docs/red/sdk-reference#creating-a-scan) in the SDK Reference for
the full parameter list.