---
title: "Build with Lazaretto. Developer guide"
description: "Developer guide: add a pre-install malware check to your agent in TypeScript or Python, MCP client code for every SDK, CI scans and 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 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}` };
    if (id.advisory_check) return { decision: "ask", why: "advisory recheck gave no answer" }; // never clear
  } 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 === "none" || scan.risk === "low") return { decision: "allow", why: scan.risk_summary };
  return { decision: "ask", why: scan.risk_summary }; // medium, unknown, or a value this code does not know
}
```

**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']}"
        if body.get("advisory_check"):
            return "ask", "advisory recheck gave no answer"  # never clear
    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"] in ("none", "low"):
        return "allow", scan["risk_summary"]
    return "ask", scan["risk_summary"]  # medium, unknown, or a value this code does not know
```

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 10 tools, 6 of them free with no key. Connect from whichever stack your agent runs on. For desktop and IDE clients, see [the setup page](https://lazaretto.dev/add).

**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)
```

**Perplexity Agent API**

```python
# From Perplexity's docs. Not run by us.
from perplexity import Perplexity

client = Perplexity()
response = client.responses.create(
    model="YOUR_MODEL",
    tools=[{
        "type": "mcp",
        "server_label": "lazaretto",
        "server_url": "https://lazaretto.dev/mcp?ref=perplexity",
        # "headers": {"X-API-Key": "YOUR_KEY"},  # for the scans that read code
    }],
    input="Check chalk@5.6.1 and chalk@5.3.0 before I install them.",
)
print(response.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

A full scan through `POST /v1/scan` returns an `attestation`, unless it ended in an error or its x402 settlement was not confirmed. The free check page does not return one. The connector tools sign nothing, so they return one only when an earlier scan of the same content already signed it. An attestation is a compact JWS signed with Ed25519 over the verdict, the risk, the rules version and `sub`, which is the SHA-256 of 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" });

// Check it names the artifact you asked about (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, unknown | How bad and what kind. Gate on this. `unknown` comes only with an `error` verdict: the scan did not finish, so no level is asserted. It is not a clear result. |
| `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 | The SHA-256 of what was read. 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.
