---
title: "Build with Lazaretto. Developer guide"
description: "Quickstart, a pre-install gate for your agent in TypeScript and Python, MCP client code for every major SDK, CI, whole-tree scans, and offline-verifiable signed attestations."
url: "https://lazaretto.dev/developers"
---

[Home](https://lazaretto.dev/) / Developers

For developers

# Build with Lazaretto

Put a pre-install check into an agent, a CI pipeline or a platform. Start with no key and nothing to install, add one when you want the code read.

[Quickstart](https://lazaretto.dev/developers#quickstart) [API reference](https://lazaretto.dev/docs/api) [OpenAPI 3.1](https://lazaretto.dev/openapi.json)

## Quickstart

1

### Check a lockfile. No key.

```sh
curl -s https://lazaretto.dev/check --data-binary @package-lock.json
```

Every exactly pinned version is matched against published malicious-package advisories. Works with npm, yarn and pnpm lockfiles. Nothing is stored.

2

### Get a free developer key

```sh
curl -s -X POST https://lazaretto.dev/v1/trial
```

Returns `api_key` with 10 full scans a day, plus ready-made setup commands. Or get one in the browser at [/start](https://lazaretto.dev/start). Keep it out of your shell history:

paste the key at the silent prompt

```sh
read -rs LAZARETTO_API_KEY && export LAZARETTO_API_KEY
```

3

### Read the code of one package

```sh
curl -s -X POST https://lazaretto.dev/v1/scan -H "X-API-Key: $LAZARETTO_API_KEY" -H 'content-type: application/json' -d '{"target":{"type":"npm_package","ref":"left-pad@1.3.0"},"depth":"full"}'
```

You get a `verdict`, a `risk` with a one-line `risk_summary`, every finding with its file and line, the SHA-256 of exactly what was read, and a signed `attestation`.

## Gate your agent's installs

The pattern that fits most agents: before the tool that runs an install executes, ask whether that exact version is listed as malware (free, one GET), and when you hold a key, have the code read too. Gate on `risk`, not on the verdict alone. This example denies listed malware and high risk, and asks a person on medium and on anything it could not check; set your own policy.

**TypeScript**

```ts
const LZ = "https://lazaretto.dev";
type Decision = { decision: "allow" | "ask" | "deny"; why: string };

/** Decide whether an agent may install name@version. Only a definite answer allows. */
export async function preInstallCheck(name: string, version: string, apiKey?: string): Promise<Decision> {
  // 1. Free, no key: is this exact version listed as malware, or already attested?
  const subject = encodeURIComponent(`npm:${name}@${version}`);
  const res = await fetch(`${LZ}/v1/attestations/${subject}`);
  if (!res.ok) return { decision: "ask", why: `identity check unavailable (${res.status})` };
  const id = await res.json();
  if (id.found) {
    // Someone already paid for a scan of this version: a signed verdict exists.
    if (id.contradicted || id.verdict === "malicious") return { decision: "deny", why: "known bad" };
    if (id.risk === "critical" || id.risk === "high") return { decision: "deny", why: `attested risk ${id.risk}` };
  } else {
    const listed = id.identity_check?.listed_as_malware;
    if (listed === true) return { decision: "deny", why: "listed as malware: " + id.identity_check.advisory_ids.join(", ") };
    if (listed !== false) return { decision: "ask", why: "no definite answer" }; // null means unchecked, never clear
  }
  if (!apiKey) return { decision: "allow", why: "not listed; code not read" };

  // 2. With a key: read the code. One credit per verdict, nothing for an error.
  const r = await fetch(`${LZ}/v1/scan`, {
    method: "POST",
    headers: { "content-type": "application/json", "x-api-key": apiKey },
    body: JSON.stringify({ target: { type: "npm_package", ref: `${name}@${version}` }, depth: "full" }),
  });
  if (!r.ok) return { decision: "ask", why: `scan unavailable (${r.status})` };
  const scan = await r.json();
  if (scan.verdict === "error") return { decision: "ask", why: "scan did not complete" };
  if (scan.risk === "critical" || scan.risk === "high") return { decision: "deny", why: scan.risk_summary };
  if (scan.risk === "medium") return { decision: "ask", why: scan.risk_summary };
  return { decision: "allow", why: scan.risk_summary };
}
```

**Python**

```python
import requests
from urllib.parse import quote

LZ = "https://lazaretto.dev"

def pre_install_check(name: str, version: str, api_key: str | None = None) -> tuple[str, str]:
    """Return ("allow" | "ask" | "deny", why). Only a definite answer allows."""
    # 1. Free, no key: is this exact version listed as malware, or already attested?
    subject = quote(f"npm:{name}@{version}", "")  # encode "/" and "@" too
    r = requests.get(f"{LZ}/v1/attestations/{subject}", timeout=10)
    if not r.ok:
        return "ask", f"identity check unavailable ({r.status_code})"
    body = r.json()
    if body.get("found"):
        # Someone already paid for a scan of this version: a signed verdict exists.
        if body.get("contradicted") or body.get("verdict") == "malicious":
            return "deny", "known bad"
        if body.get("risk") in ("critical", "high"):
            return "deny", f"attested risk {body['risk']}"
    else:
        ident = body.get("identity_check") or {}
        listed = ident.get("listed_as_malware")
        if listed is True:
            return "deny", "listed as malware: " + ", ".join(ident.get("advisory_ids", []))
        if listed is not False:
            return "ask", "no definite answer"  # None means unchecked, never clear
    if not api_key:
        return "allow", "not listed; code not read"

    # 2. With a key: read the code. One credit per verdict, nothing for an error.
    r = requests.post(
        f"{LZ}/v1/scan",
        headers={"x-api-key": api_key},
        json={"target": {"type": "npm_package", "ref": f"{name}@{version}"}, "depth": "full"},
        timeout=60,
    )
    if not r.ok:
        return "ask", f"scan unavailable ({r.status_code})"
    scan = r.json()
    if scan["verdict"] == "error":
        return "ask", "scan did not complete"
    if scan["risk"] in ("critical", "high"):
        return "deny", scan["risk_summary"]
    if scan["risk"] == "medium":
        return "ask", scan["risk_summary"]
    return "allow", scan["risk_summary"]
```

Only a definite answer allows. A failed or rate-limited call returns `ask`, and so does `listed_as_malware: null`, which means the advisory corpus could not be consulted or could not be matched to that version. For Claude Code there is a ready-made version of the first step as a hook, the [lazaretto-guard plugin](https://lazaretto.dev/agents#guard).

## Use the MCP server from code

`https://lazaretto.dev/mcp` is a stateless Streamable HTTP server with 9 tools, 5 of them free with no key. Connect from whichever stack your agent runs on. For desktop and IDE clients, see [the agents page](https://lazaretto.dev/agents).

**TypeScript SDK**

```ts
// npm i @modelcontextprotocol/client
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";

const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(
  new StreamableHTTPClientTransport(new URL("https://lazaretto.dev/mcp"), {
    // Omit requestInit for the free tools only.
    requestInit: { headers: { "X-API-Key": process.env.LAZARETTO_API_KEY! } },
  }),
);

const result = await client.callTool({
  name: "find_attestation",
  arguments: { subject: "npm:chalk@5.6.1" },
});
await client.close();
```

**TypeScript SDK v1**

```ts
// npm i @modelcontextprotocol/sdk   (v1)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL("https://lazaretto.dev/mcp")));

const result = await client.callTool({
  name: "check_lockfile",
  arguments: { packages: ["chalk@5.6.1", "express@4.21.2"] },
});
```

**Python SDK**

```python
# pip install "mcp>=2,<3"
import asyncio
from mcp import Client

async def main() -> None:
    async with Client("https://lazaretto.dev/mcp") as client:
        result = await client.call_tool("find_attestation", {"subject": "npm:chalk@5.6.1"})
        print(result)

asyncio.run(main())
# For the metered tools, pass an httpx2.AsyncClient carrying the X-API-Key header
# to mcp.client.streamable_http.streamable_http_client and hand that to Client.
```

**OpenAI Agents**

```python
# pip install openai-agents
import asyncio, os
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp

async def main() -> None:
    async with MCPServerStreamableHttp(
        name="lazaretto",
        params={
            "url": "https://lazaretto.dev/mcp",
            "headers": {"X-API-Key": os.environ["LAZARETTO_API_KEY"]},  # omit for the free tools
        },
        cache_tools_list=True,
    ) as lazaretto:
        agent = Agent(
            name="Installer",
            instructions="Before installing any package, check it with the lazaretto tools.",
            mcp_servers=[lazaretto],
        )
        result = await Runner.run(agent, "Is chalk@5.6.1 fine to install?")
        print(result.final_output)

asyncio.run(main())
```

**OpenAI Responses**

```python
from openai import OpenAI

client = OpenAI()
resp = client.responses.create(
    model="YOUR_MODEL",
    tools=[{
        "type": "mcp",
        "server_label": "lazaretto",
        "server_url": "https://lazaretto.dev/mcp",
        "headers": {"X-API-Key": "YOUR_KEY"},  # omit for the free tools
        "require_approval": "never",
    }],
    input="Check chalk@5.6.1 and debug@4.4.2 before I install them.",
)
print(resp.output_text)
```

**LangChain**

```python
# pip install langchain-mcp-adapters   (pins mcp<2)
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient

async def main() -> None:
    client = MultiServerMCPClient({
        "lazaretto": {
            "transport": "http",
            "url": "https://lazaretto.dev/mcp",
            "headers": {"X-API-Key": "YOUR_KEY"},  # omit for the free tools
        }
    })
    tools = await client.get_tools()  # hand these to your LangGraph agent
    print([t.name for t in tools])

asyncio.run(main())
```

**Vercel AI SDK**

```ts
// npm i ai @ai-sdk/mcp
import { createMCPClient } from "@ai-sdk/mcp";

const mcp = await createMCPClient({
  transport: {
    type: "http",
    url: "https://lazaretto.dev/mcp",
    headers: { "X-API-Key": process.env.LAZARETTO_API_KEY! }, // omit for the free tools
  },
});
try {
  const tools = await mcp.tools(); // pass to generateText or streamText
} finally {
  await mcp.close();
}
```

## CI: scan what each pull request adds

The GitHub Action fails the build when a pinned dependency is known malware, free and with no key. Add a key and it also reads the code of each dependency version a pull request adds, which is where a brand-new malicious release hides before any advisory exists. A pull request that changes no dependencies costs nothing.

.github/workflows/lazaretto.yml

```yaml
name: Lazaretto
on: [pull_request]
permissions:
  contents: read
  pull-requests: write
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: jamesdfinance-dev/lazaretto-scan-action@v2
        with:
          api-key: ${{ secrets.LAZARETTO_API_KEY }}
```

Store the key with `gh secret set LAZARETTO_API_KEY`. Never put it in the workflow file. [The Action on GitHub Marketplace](https://github.com/marketplace/actions/lazaretto-scan).

## Whole dependency trees

| Endpoint | What it answers | Cost |
| --- | --- | --- |
| `POST /v1/lockfile` | Every pinned version in a lockfile, matched against malicious-package advisories. JSON. | free |
| `POST /check` | The same check rendered as plain text for a terminal. | free |
| `POST /v1/lockfile/diff` | Base and head lockfiles in, exactly the versions a change adds out. | free |
| `POST /v1/scan/batch` | Reads the code of every pinned dependency, worst verdict first, up to 25 per call. | one credit per package |
| `POST /v1/watch` | Registers a dependency set; reading it later reports anything listed as malware since. | one credit to create, free to read |

An empty `malicious` list is an all-clear only when `unverified` is also empty and `truncated` is false. On the batch scan, read `complete_coverage` before trusting the result.

## Checking MCP servers before you connect

An MCP server's tool descriptions are text your model obeys. `scan_mcp_server` (or `POST /v1/scan` with `"type":"mcp_server"`) calls `initialize` and `tools/list` on the server, never its tools, and reads what it advertises for hidden directives, exfiltration parameters and cross-server shadowing. Most servers run over stdio and have no URL: send the tool list your client already holds with `"type":"mcp_tools"` or the `check_mcp_tools` tool, and nothing is contacted.

```sh
curl -s -X POST https://lazaretto.dev/v1/scan -H "X-API-Key: $LAZARETTO_API_KEY" \
  -H 'content-type: application/json' \
  -d '{"target":{"type":"mcp_server","ref":"https://the-server.example/mcp"}}'
```

## Signed attestations

Every non-error scan returns an `attestation`: a compact JWS signed with Ed25519 over the verdict, the risk, the rules version and `sub`, which is the SHA-256 of exactly what was read (or, for a release the registry has already pulled, its package identity). Pass it to another agent, put it in a README, or store it next to a lockfile. Anyone can verify it offline, with no call to us and no need to trust whoever handed it over.

Verify offline with jose

```ts
import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://lazaretto.dev/.well-known/jwks.json"));
const { payload } = await jwtVerify(attestation, JWKS, { issuer: "https://lazaretto.dev" });

// Bind it to the bytes you are about to run (sub is a package identity only for a pulled release).
if (payload.sub !== "sha256:" + yourArtifactHash) throw new Error("attestation is for a different artifact");
console.log(payload.verdict, payload.risk, payload.rules_version);
```

Or verify online with `POST /v1/verify`, which also flags a verdict the current data now contradicts. Look up whether anyone has attested a package, an MCP endpoint or a hash with `GET /v1/attestations/{subject}` or the `find_attestation` tool, both free. There is no expiry on purpose: a verdict means "clear at scan time under these rules", never "clear forever".

## Verdicts, risk and billing

| Field | Values | Read it as |
| --- | --- | --- |
| `verdict` | malicious, flagged, clear, error | `malicious` is reserved for indicator-backed matches. Heuristic rules only reach `flagged`. `clear` means nothing matched. |
| `risk` | critical, high, medium, low, none | How bad and what kind. Gate on this. |
| `confidence` | high, medium, low | Lower when part of the artifact could not be read or a source could not be checked. |
| `known_bad.matched` | true, false, null | `null` means the check could not run. Never read it as false. |
| `analysis_partial` | true when set | Some of the artifact was unreadable, so "no rule fired" is a weaker statement. |
| `target_hash` | sha256, or empty | Binds the verdict to the bytes, reproducible by consumers. Empty for a verdict on a package identity the registry has pulled. |

Billing is one credit per verdict. An `error` is never billed, over the API, MCP or x402. A scan is $0.03 per call over x402, or credits from packs: `starter` $3 for 150 scans, `pro` $12 for 700 scans, `scale` $25 for 1,600 scans. Packs never expire and have no daily cap. See [pricing](https://lazaretto.dev/pricing).

## Rate limits

Limits are counted per source (your key, or your address without one) in one-minute windows. Most routes share a single counter, and each refuses once that counter passes its own ceiling, so a burst of lookups also counts against a batch scan made in the same minute.

| Route | Ceiling per minute |
| --- | --- |
| Scans, credit top-ups and the MCP endpoint | 120 |
| Free lockfile check | 60 |
| Free lockfile check of more than 50 packages (its own counter) | 10 |
| Known-bad and attestation lookups | 60 |
| Batch scans | 6 |

A 429 carries `Retry-After`, and rate-limited routes report `X-RateLimit-Remaining`.

## Reference

### [API reference](https://lazaretto.dev/docs/api)

Every endpoint, field and status code.

### [OpenAPI 3.1](https://lazaretto.dev/openapi.json)

The machine-readable contract.

### [llms.txt](https://lazaretto.dev/llms.txt)

The whole service in one page, for a model.

### [Rule catalog](https://lazaretto.dev/v1/rules)

Every rule id, category and severity.

---

Machine-readable index: https://lazaretto.dev/llms.txt. OpenAPI: https://lazaretto.dev/openapi.json. MCP endpoint: https://lazaretto.dev/mcp. Any page on this site is available as markdown by adding .md to its path.
