VASTlint

VAST tag validation in Python with vastlint

Short answer: run pip install vastlint and call vastlint.validate(xml). It validates VAST XML in-process and returns a structured result you can hand straight to an API response or a data pipeline.

The package wraps the same Rust core used by the CLI, Go binding, and web validator through a stable FFI C API. No subprocess to manage, no network hop, a good fit for ad-ops tooling, a FastAPI or Flask service, a Django backend, or an Airflow job that validates creatives and returns structured results.

Why use the Python package

  • In-process validation, with no subprocess or external service
  • Same core rule coverage as the CLI, web app, and other bindings
  • Structured result that serialises to JSON for any API response
  • Per-call rule_overrides and wrapper-depth options
  • Zero Python dependencies, prebuilt wheels, no Rust toolchain

Install

pip install vastlint

Wheels ship a platform-matched libvastlint shared library, so there is nothing else to build. Requires Python 3.9 or newer.

Minimal example

import vastlint

result = vastlint.validate(vast_xml)

if result.valid:
    print("clean tag")
else:
    print(result.summary.errors)
    print(result.issues[0].message)

print(result.to_json(indent=2))

FastAPI endpoint

from fastapi import FastAPI
from pydantic import BaseModel
import vastlint

app = FastAPI()


class VastPayload(BaseModel):
    xml: str
    max_wrapper_depth: int = 5
    rule_overrides: dict[str, str] | None = None


@app.post("/validate")
def validate(payload: VastPayload):
    result = vastlint.validate(
        payload.xml,
        max_wrapper_depth=payload.max_wrapper_depth,
        rule_overrides=payload.rule_overrides,
    )
    return result.to_dict()

The result shape is stable and frontend-friendly: version, an issues array (each with id, severity, message, path, spec_ref, line, and col), and a summary with errors, warnings, and infos counts.

How fast is it

Validation runs in-process against the Rust core, so the per-tag cost is dominated by the core, not by Python. The numbers below are single-core, single-threaded, and include the full Python round trip: the FFI call, the JSON result, and parsing it into dataclasses. They are what a Python caller actually sees, not a raw-core microbenchmark.

Tag sizeMedian latencyThroughput (1 core)
7 KB86 µs~11,000 tags/sec
17 KB0.36 ms~2,800 tags/sec
23 KB0.57 ms~1,700 tags/sec
44 KB2.1 ms~470 tags/sec
347 KB4.7 ms~210 tags/sec

Measured on an Apple M4, single core, against the project's VAST corpus (200 tags per size bucket, warm). Production tags typically run 17 to 44 KB, which lands in the sub-millisecond to low-millisecond range per tag. Throughput scales close to linearly across cores with a process pool, since each validation is independent and holds no shared state.

Why validate VAST in-process

A malformed VAST tag is a billed impression that never renders: the player loads, the auction clears, the publisher is charged, and the viewer sees a blank slot. The failure shows up in the player, not your application logs, so a bad tag can sit in rotation until someone traces a revenue dip back to it.

The package calls the same Rust core the rest of the pipeline uses, in-process through FFI. No subprocess to spawn per request, no validation microservice to keep alive, and the XML never leaves your process. Because the result serialises to a stable shape, the same call that gates a creative on the backend also feeds the reviewer's screen.

Where it lands in an ad pipeline

Python tends to run the data, QA, and tooling side of the ad stack, so validation lands where creatives and people meet the system.

  • Creative ingestion APIs: validate in the endpoint when a tag is submitted, and reject it with the specific issues before it is ever trafficked.
  • Data and QA pipelines: an Airflow or cron job that re-validates every active tag and flags drift after upstream changes.
  • Notebooks and analysis: pull a sample of live tags and quantify error and warning rates across partners.
  • Demand-partner intake: validate sample adm payloads from a new SSP or DSP partner during onboarding and report error rates as part of the SLA.

Use it in agentic and AdCP workflows

The newer reason to reach for this package is agent-to-agent advertising. The Ad Context Protocol (AdCP) is an open standard, built on the Model Context Protocol, where buyer and seller agents negotiate media and exchange creatives directly. Its creative protocol defines a first-class vast asset: a buyer agent calls sync_creatives to push a VAST tag to a seller, either as inline content or a url, tagged with a vast_version, a vpaid_enabled flag, and a max_wrapper_depth.

That asset shape maps almost one to one onto this package. A seller agent should not accept a creative just because the message is well-formed: the content still has to be valid VAST at the declared version. vastlint is the deterministic gate. It is faster than the model in the loop, it never hallucinates a verdict, and it returns the same structured issues every time, which is exactly what a seller agent needs to reject a tag without a human watching.

import vastlint

# AdCP "vast" creative asset arriving on a sync_creatives call.
# (delivery_type "inline" carries the XML in `content`.)
asset = {
    "asset_type": "vast",
    "delivery_type": "inline",
    "content": "<VAST version=\"4.2\"> ... </VAST>",
    "vast_version": "4.2",
    "vpaid_enabled": False,
    "max_wrapper_depth": 5,
}

# The seller agent's own format policy, expressed as rule overrides.
SELLER_POLICY = {
    "VAST-2.0-mediafile-https": "error",   # secure media only
    "VAST-4.1-mezzanine-recommended": "off",
}


def accept_creative(asset: dict) -> dict:
    if asset["asset_type"] != "vast" or asset["delivery_type"] != "inline":
        return {"status": "skipped"}

    result = vastlint.validate(
        asset["content"],
        max_wrapper_depth=asset.get("max_wrapper_depth", 5),
        rule_overrides=SELLER_POLICY,
    )

    if not result.valid:
        # Reject deterministically, with machine-readable reasons the
        # buyer agent can act on without a human in the loop.
        return {
            "status": "rejected",
            "issues": [i.to_dict() for i in result.issues],
        }

    return {"status": "approved", "vast_version": result.version}

The mapping is direct: AdCP's max_wrapper_depth is the same argument on vastlint.validate, the declared vast_version can be checked against result.version to catch a tag that lies about its own version, and a seller that sets vpaid_enabled: false can reject on vastlint's VPAID detection. The per-rule overrides let each agent encode its own format policy instead of a single global pass or fail.

  • Seller-side intake: validate the vast asset on every sync_creatives call before it enters delivery, and return the issues as the rejection reason.
  • Buyer-side pre-flight: validate before you sync, especially when the tag came out of an LLM-driven build_creative step, so it is not bounced downstream.
  • LLM creative agents: use it as the verifier in a generate, validate, repair loop. The spec_ref and message on each issue are the feedback a model can act on.
  • Eval and conformance: score a fleet of agent outputs against a held-out set, and see which VAST rules each one gets wrong most.

If your agent speaks MCP rather than importing Python, the same Rust core is also exposed as an MCP server, so validation is callable as a tool over the protocol. See VAST validation in agentic ad delivery for the wider picture.

Training and fine-tuning models on VAST

If you are building a model that writes or repairs VAST, the hard part is not generation, it is knowing when the output is correct. VAST validity is verifiable: a tag either conforms to the IAB spec at its declared version or it does not, and vastlint computes that verdict deterministically in well under a millisecond. That makes it a natural fit for the parts of the training loop that need a ground-truth signal rather than a learned judge.

import vastlint

# Rejection sampling: keep only valid generations to build a clean
# supervised fine-tuning set, or use the score as a reward signal.
def vast_reward(xml: str) -> float:
    result = vastlint.validate(xml)
    if result.valid:
        return 1.0
    s = result.summary
    return -1.0 * s.errors - 0.25 * s.warnings   # shaped, not just pass/fail


def build_sft_examples(prompt: str, samples: list[str]) -> list[dict]:
    # Generate N candidates upstream, keep the ones that actually validate.
    return [
        {"prompt": prompt, "completion": xml}
        for xml in samples
        if vastlint.validate(xml).valid
    ]
  • Training-data curation: filter a raw corpus of tags down to the valid ones before supervised fine-tuning, or label each example with its issue list so the model learns from clean targets instead of broken ones.
  • Verifiable reward: use validity as the reward in an RL or RLVR setup. Because the result carries per-rule counts, you can shape the reward (hard-fail on any error, partial credit for getting from ten errors to one) instead of a flat pass or fail.
  • Rejection sampling: generate several candidates per prompt, keep only the ones that validate, and turn the survivors into fine-tuning pairs. A repair dataset is just broken tag in, validated fix out.
  • Eval across checkpoints: score every checkpoint on a held-out set and track both the valid rate and which rules the model gets wrong most, so regressions show up as a specific rule, not a single moving number.

The reason to run this in-process matters more here than anywhere else: training and rejection sampling touch millions of generations, so a subprocess or a network call per sample would dominate the loop. A function call against the Rust core does not, and because each validation is independent you can fan it out across a process pool to keep up with generation throughput. The verifier is also the one part of the loop that never drifts, which is the property you want from anything you are optimising against.

When not to use the Python package

If you just want a quick manual answer, use the web validator. If you only have a live tag URL, use the tester. If the real problem is wrapper depth or redirect chains, jump to the inspector.

Related reading