Audience: anyone building a conformant L0 webGCP server. Implementer-facing.
This is not the spec. The spec is the normative authority at https://webgcp.org/spec/v0.1/ (markdown source: webgcp-v01-final.md). This doc is the implementer's guide — what to build, in what order, with concrete pointers to a working reference. Read the spec for the rules; read this for the construction sequence.
What "MVP" means here: the minimum a single team needs to ship to claim L0 (or L0-restricted-domain) per the spec. Once L0 ships, you're a conformant implementation; you're listed at https://webgcp.org/implementations/; and your 3-month production-soak clock (Step 3 of the standards playbook) starts ticking.
Five artifacts. Three live, two on disk.
| # | Artifact | Lives at | Spec section |
|---|---|---|---|
| 1 | DNS SVCB record | _webgcp.<your-host> (TYPE 64) |
§5.1.1 |
| 2 | Server descriptor (JSON) | https://<your-host>/.well-known/webgcp |
§5.1 |
| 3 | One KB manifest (JSON) | https://<your-host>/<path>/manifests/<kb-id>-v0.1.json (path is operator-chosen) |
§5.2 |
| 4 | One bundle contract (JSON) | https://<your-host>/<path>/bundles/<contract>-v0.1.json (path is operator-chosen) |
§5.3 |
| 5 | Dynamic query endpoint (POST handler) | https://<your-host>/webgcp/v0/query |
§6.3, §6.4 |
Plus two conformance fixtures (downloaded from webgcp.org, not authored by you) that you run against your endpoint:
WGCP-L0-001 — happy path: typed bundle response with _meta provenance on every field.WGCP-L0-002 — out-of-scope: query for a non-applicable topic returns status: "out_of_scope" typed failure.WGCP-L0-003 — DNS-layer SVCB discovery resolves and points at your descriptor.All three must pass. (L0-003 fails if you're under a restricted DNS zone like .web.app / .github.io / .netlify.app / .vercel.app — in which case you claim L0-restricted-domain instead, per §5.1.1.)
Each step has a clear "done" signal. Don't skip ahead — later steps assume earlier ones.
You need a domain (or subdomain) where you control authoritative DNS and can serve HTTPS. Both are required.
Acceptable hosts (full L0):
- news.example.com — apex or subdomain of a domain you've registered
- acme-skills.io — apex
- Anything where you can add an SVCB record at _webgcp.<host> and serve HTTPS
Restricted-domain hosts (L0-restricted-domain only):
- *.web.app / *.firebaseapp.com (Firebase Hosting auto-domains)
- *.github.io (GitHub Pages)
- *.netlify.app, *.vercel.app, *.pages.dev
- Any platform that doesn't let you manage authoritative DNS records at your hostname
Done when: you can curl -sI https://<your-host>/ and get an HTTPS 200 (any content). DNS records you control can be added.
The protocol is implementation-neutral. Acceptable backing stores at L0: - Bundled fixture data (Python dict, JSON file, etc.) — the simplest demo case - SQLite, Postgres+pgvector, MySQL — small structured datasets - Firestore, DynamoDB, MongoDB — document stores - BigQuery, Snowflake, Databricks — analytical / large-scale - A graph DB (Neo4j, Spanner Graph, BigQuery Property Graph)
L0 doesn't require vector search or graph traversal — direct lookup is enough. That's L1 territory.
Done when: you can write a function fetch(query, filter) → list_of_records that returns matching records for a topic-filtered text query, against any of the above.
The manifest describes what your KB is for and when it applies. This is the meta-knowledge layer.
Required top-level fields (full schema at https://webgcp.org/schemas/manifest/v0.1.json):
manifest_version — semver, e.g., "0.1.0"kb.id — URN, e.g., urn:webgcp:news.example.com:news-kbkb.owner — your operator namekb.status — "active" | "deprecated" | "superseded"purpose.summary — one paragraph plain Englishpurpose.declared_capabilities — array of strings (your "API surface" in tag form)applicability.in_scope_when[] — predicates that mean "valid query"applicability.out_of_scope_when[] — predicates that mean "return typed out_of_scope failure"inputs.required[], inputs.optional[], inputs.on_missingauthority.canonical_sources[] + authority.freshness_sla (ISO 8601 duration)governance.acl_policy_ref, governance.pii_class ("none" | "pseudonymous" | "personal" | "sensitive")versioning.manifest_semver + versioning.artifact_index_versionWorked example: https://the-ai-news.web.app/webgcp/manifests/news-kb-v0.1.json. Copy its structure, replace the AI-news-specific content with your domain's content.
The applicability predicate format. Each entry in in_scope_when[] and out_of_scope_when[] is a tagged JSON object with a predicate field naming the check, plus type-specific fields:
{
"applicability": {
"in_scope_when": [
{ "predicate": "topic_in", "values": ["ai-research", "machine-learning", "llms"] },
{ "predicate": "time_range", "min": "2026-01-01", "max": "now()" },
{ "predicate": "language_eq", "value": "en" }
],
"out_of_scope_when": [
{ "predicate": "topic_outside_domain", "note": "Non-AI topics (sports, politics, finance)" },
{ "predicate": "private_facts", "note": "Queries about non-public individual data" }
]
}
}
The predicate names are operator-defined — there is no closed enum at v0.1. You name predicates after what they check. The note field is human-readable context for agents deciding whether to call. What matters is that your query endpoint enforces these declarations (see §2.5 below).
Common trip: applicability.out_of_scope_when is load-bearing. The whole point of the manifest is that an agent can tell — before calling — whether your KB will return a meaningful answer or out_of_scope. Don't leave it empty. A manifest without out_of_scope_when entries is a promise to answer everything — which is almost never true.
Done when: you publish the manifest at a stable URL and curl -s <url> | jq .kb.id returns the right URN.
The contract is what an agent declares it needs. Defines the typed response shape.
Required top-level (full schema at /schemas/bundle/v0.1.json):
contract_uri — URN, e.g., urn:webgcp:news.example.com:news-article-retrieval/v0.1contract_version — semverpreferred_source_kb — URN of the manifest's kb.idinput_schema — JSON Schema for what the agent sends in filterfields[] — list of bundle fields (top-level), each with name, type, required, primitive_hint, authority_required, on_failuretype_definitions — inline type definitions referenced by fields[].items_typePer-field MUSTs (§5.3, §5.5, §5.6):
- primitive_hint — one of direct_lookup | hierarchical_tree | vector_hybrid | graph_traversal | semantic_layer (your endpoint's primitives at L0 — usually just direct_lookup is enough)
- authority_required — canonical | derived | any
- on_failure — hard_fail | fallback | annotate | abstain
- acl_class, pii_class
Worked example: /webgcp/bundles/news-article-retrieval-v0.1.json at the-ai-news. 195 lines of JSON, covers 8 typed Article fields.
Done when: you can write down (in JSON) what shape your endpoint returns, with _meta placeholders.
POST handler. Receives KL-Query JSON, returns typed bundle JSON.
Pseudocode (full reference impl: services/webgcp-query/main.py in the-ai-news's source repo, ~280 lines Python):
@http_post("/webgcp/v0/query")
def query(request):
# CORS preflight first
if request.method == "OPTIONS":
return 204, {"Access-Control-Allow-*": ..., "Access-Control-Allow-Methods": "POST, OPTIONS"}
body = parse_json(request.body)
# Validate bundle contract URI
if body["bundle"]["contract_uri"] != KNOWN_CONTRACT_URI:
return typed_failure("manifest_mismatch", "...", http_code=409)
# Validate scope (ACL §5.9)
if not scope_includes_our_kb(body["scope"]["allow_kbs"]):
return typed_failure("acl_denied", "...", http_code=403)
# Validate input (manifest's required inputs)
if not body.get("filter", {}).get("query"):
return typed_failure("needs_input", "...", http_code=200, extra={"missing_inputs": ["filter.query"]})
# Applicability check (§5.4) — out-of-scope returns typed failure, NOT empty results
scope_check = check_applicability(body["filter"])
if scope_check:
return typed_failure("out_of_scope", scope_check["message"], http_code=200,
extra={"out_of_scope_reason": scope_check["reason"]})
# Retrieve
results = backing_store.fetch(body["filter"])
# Build typed bundle with _meta on every field (§5.5)
return {
"status": "ok",
"bundle": {
"articles": { # or whatever your bundle field is named
"value": results,
"_meta": {
"source_kb": OUR_KB_URN,
"source_uri": MANIFEST_URL,
"retrieved_at": iso_now(),
"retriever_version": "...",
"authority": "derived",
"confidence": compute_confidence(results),
"manifest_hash": MANIFEST_HASH
}
}
},
"completeness": "strict_satisfied" if results else "degraded",
"trace": {"trace_id": body.get("trace", {}).get("trace_id", gen_trace_id()), "spans": [...]},
"content_origin_summary": {"first_party": len(results), "third_party_trusted": 0, "third_party_untrusted": 0}
}
Implementing check_applicability() — the reference approach:
At L0, applicability checking is server-side logic that enforces your manifest's out_of_scope_when declarations. The reference implementation uses keyword-matching against declared topic categories:
# Your in-scope topics (from manifest.applicability.in_scope_when[0].values)
IN_SCOPE_TOPICS = {"ai-research", "machine-learning", "llms", "robotics", ...}
# Negative-signal keywords (from manifest.applicability.out_of_scope_when)
OUT_OF_SCOPE_SIGNALS = ["nfl", "playoff", "stock price", "election", "recipe", ...]
def check_applicability(filter_obj):
"""Returns None if in-scope, or {"reason": ..., "message": ...} if out-of-scope."""
query = filter_obj.get("query", "").lower()
category = filter_obj.get("category", "")
# If caller specified a category, check it directly
if category and category not in IN_SCOPE_TOPICS:
return {"reason": "category_not_served",
"message": f"category '{category}' is outside this KB's scope"}
# Keyword signal: reject queries clearly outside the domain
for signal in OUT_OF_SCOPE_SIGNALS:
if signal in query:
return {"reason": "non_ai_topic",
"message": f"query '{query}' is outside this KB's applicability frame"}
return None # in-scope — proceed to retrieval
This is deliberately simple. At L0 you don't need an ML classifier or complex predicate engine — a keyword list that catches obviously-wrong queries is sufficient to pass L0-002. The key contract: if your manifest declares something out-of-scope, your endpoint must actually reject it rather than returning empty results.
Typed failures (§6.7) — handle these 5 minimally:
- needs_input (HTTP 200) — required input missing
- out_of_scope (HTTP 200) — query outside the manifest's applicability frame
- manifest_mismatch (HTTP 409) — wrong contract_uri
- acl_denied (HTTP 403) — scope.allow_kbs excludes your KB
- method_not_allowed (HTTP 405) — non-POST request
Done when: curl -X POST -d '{...}' /webgcp/v0/query returns a JSON body matching §6.4 for valid inputs and matching §6.7 for invalid ones.
The descriptor is a small JSON document advertising what you serve.
Required fields:
- spec_version: "0.1"
- conformance_level: "L0" (or "L0-restricted-domain" if you're on .web.app / .github.io / etc.)
- server_id: "urn:webgcp:<your-host>"
- operator: "<your name>"
- endpoints — object with query, manifests, bundle_contracts
- manifests_available[] and bundle_contracts_available[] — lists pointing at the artifacts from §2.3 / §2.4
- supported_features: ["bundle_contracts", "direct_lookup"] at L0
- contact: "you@example.com"
If L0-restricted-domain: also add restricted_domain_reason naming the platform.
Worked example: https://the-ai-news.web.app/.well-known/webgcp. 60 lines of JSON.
Done when: curl -sI https://<your-host>/.well-known/webgcp returns HTTP 200 with Content-Type: application/json and an Access-Control-Allow-Origin: * header. CORS open: the descriptor MUST be reachable from any origin.
One record at your registrar:
_webgcp.<your-host>. 3600 IN SVCB 1 <your-host>.
Priority 1 (ServiceMode), TargetName = your host, no SvcParams. See webgcporg/site/SVCB-AT-SQUARESPACE.md for a Squarespace-specific walkthrough including fallbacks if your registrar's UI doesn't expose SVCB.
Done when:
curl -s -H 'accept: application/dns-json' \
"https://cloudflare-dns.com/dns-query?name=_webgcp.<your-host>&type=64" | jq .Answer
returns an entry with type: 64 and data starting with 1.
If your host is .web.app / .github.io / .netlify.app / .vercel.app / .pages.dev — skip this step; declare L0-restricted-domain in the descriptor instead. You can come back to it after attaching a custom domain.
Download and run the conformance runner (zero dependencies — Python 3.8+ stdlib only):
curl -sO https://webgcp.org/conformance/v0.1/runner.py
python3 runner.py --target https://<your-host>/webgcp/v0/query
The runner auto-discovers your server descriptor, manifest, and bundle contract from /.well-known/webgcp, then evaluates all 27 assertions across the three L0 fixtures:
_meta provenance on every fieldstatus: "out_of_scope" typed failure_webgcp.<host> resolves and points at your descriptorOptions:
# If you're on .web.app / .github.io / .netlify.app (no DNS control):
python3 runner.py --target https://<your-host>/webgcp/v0/query --skip-dns
# Provide a known in-scope query for L0-001 (default: auto from manifest):
python3 runner.py --target https://<your-host>/webgcp/v0/query --query "transformers"
# Verbose (show passing assertions too):
python3 runner.py --target https://<your-host>/webgcp/v0/query --verbose
Exit code 0 = all assertions pass. Exit code 1 = at least one failure (printed with assertion ID and spec reference).
Done when: all three fixtures pass (or L0-003 is skipped with --skip-dns and your descriptor declares L0-restricted-domain).
https://webgcp.org/.well-known/webgcp so you're added to https://webgcp.org/implementations/.Done when: another team can read your /.well-known/webgcp and build to your bundle contract for one of their agents. That's the second-implementer test for your specific surface.
The-ai-news at https://the-ai-news.web.app is the live L0-restricted-domain reference. ~280 lines of Python total for the query endpoint; 4 static JSON files; 8 fixture articles bundled in-process.
| Source path (in the-ai-news repo) | Purpose | Approx LOC |
|---|---|---|
services/webgcp-query/main.py |
The query endpoint — Flask + functions-framework, deploys as Cloud Function Gen2 | 280 |
services/webgcp-query/requirements.txt |
Two deps: functions-framework, flask |
2 |
services/webgcp-query/README.md |
Deploy + test commands | 90 |
public/.well-known/webgcp |
Server descriptor (static JSON) | 60 |
public/webgcp/manifests/news-kb-v0.1.json |
KB manifest | 110 |
public/webgcp/bundles/news-article-retrieval-v0.1.json |
Bundle contract | 180 |
public/webgcp/conformance/v0.1/WGCP-L0-{001,002,003}.json |
Fixture copies (downloaded from webgcp.org) | 3 × ~150 |
firebase.json (delta only) |
Headers for .well-known/webgcp + /webgcp/** paths; rewrite for the query endpoint |
~30 added |
docs/webGCP-context-integration.md |
Walkthrough of the integration (Phase 1 + Phase 2) | 200 |
Total new code + config: under 1100 lines. A weekend's work if you've got the host + backing store.
Skim that source to see what each spec MUST looks like in practice. Don't rewrite from scratch — adapt.
These belong to L1+ or to Commerce-layer (separate stack). Skip them.
| Concern | Where it belongs | Skip in L0 |
|---|---|---|
| Vector search | L1 (§4 — "multiple primitives") | Yes |
| Graph traversal | L1 / L4 (§5.7) | Yes |
| Hierarchical-tree retrieval | L1 (§5.7) | Yes |
| Signed manifests (Cloud KMS, etc.) | L2 — Governed (§4) | Yes |
| Right-to-delete | L2 (§4) | Yes |
| Federation / cross-server query | L3 (§4, §8) | Yes |
.well-known/webgcp/keys.json trust roots |
L2+ (§5.1) | Yes |
| Adversarial test suite WGCP-ADV-* | L1+ (§9.4) | Yes |
| Payment / x402 / settlement | Commerce layer (§0.7 separates this from Context) | Yes |
| Agent marketplace / graph-authority routing | Commerce layer | Yes |
| Versioned artifact index | L1 (§4) | Yes |
| Refresh SLAs at field level | L1 (§4) | Yes (manifest-level freshness_sla is enough for L0) |
mandatory SvcParam in the SVCB record |
v0.2 (§5.1.1) | Yes |
If you find yourself building any of these for L0, stop. They don't gate the standard's L0 conformance and they're better added once your L0 surface is soaked for ≥3 months and you have real friction to design against.
Hard gates (all must hold to claim L0 in your descriptor):
/.well-known/webgcp returns valid JSON with spec_version: "0.1" and conformance_level: "L0"._webgcp.<host> resolves with Status: 0 and type: 64. (Or you claim L0-restricted-domain and document why.)/schemas/manifest/v0.1.json./schemas/bundle/v0.1.json._meta provenance on every value, returns typed failures (not free-form errors) for the 5 failure modes in §2.5.L0-restricted-domain.Soft "should also do":
- Listed at /implementations/ on webgcp.org
- A docs/webGCP-context-integration.md (or equivalent) in your repo explaining the integration for your team
- A way to update your manifest version when content meaningfully changes (no schema yet for the changefeed — that's L4)
When all 8 hard gates hold, you're L0. Update your descriptor with conformance_first_attained_at and start the 3-month soak.
These are the ones the reference implementation tripped over. Watch for them.
_meta placement¶_meta goes on the bundle field, not on each individual record inside an array. So for a field articles that returns [Article, Article, Article], the shape is:
{
"bundle": {
"articles": {
"value": [{"id": "...", "title": "..."}, ...],
"_meta": { "source_kb": "...", "authority": "derived", ... }
}
}
}
NOT one _meta per Article. If you want per-record provenance for L1+, that's a separate design — but L0 is field-level only.
canonical — value came directly from a system of record listed in the manifest's authority.canonical_sources.derived — computed from canonical (joins, aggregates, vector search results, LLM-generated summaries).inferred — model output that could be wrong.Search results are derived, not canonical. Even though the underlying titles come from a canonical source, the ranked-results-for-this-query shape is a derived computation. Don't tag a vector-search result set as canonical.
Per §5.4: if the manifest's applicability.out_of_scope_when matches, you MUST return status: "out_of_scope". Never return status: "ok" with bundle.articles.value: [] for an out-of-scope query — that's the "franken-answer" anti-pattern §5.4 specifically forbids.
Don't cite URLs in your descriptor or manifest that return 404. If you advertise manifests_available[].url, that URL must serve the manifest right now. Same for bundle_contracts_available[].url. The spec's "URL-honesty" principle (per §5.11 + the v0.1 verification suite) is binary: cited URLs must work or be future-tense.
The descriptor, manifest, bundle contract, and conformance fixture files all MUST be served with Access-Control-Allow-Origin: *. They're public protocol surfaces — agents from any origin must be able to fetch them.
JSON endpoints serve as application/json; charset=utf-8. The descriptor at /.well-known/webgcp has no file extension — explicitly set Content-Type via your hosting platform's header config (Firebase Hosting: firebase.json headers section; nginx: add_header Content-Type ...).
Per §6.7, most typed failures ride on HTTP 200 with a JSON body whose status field names the failure type. The exceptions: manifest_mismatch → 409, acl_denied → 403, auth_required → 401, kb_unavailable → 503. Don't reflexively map every failure to a 4xx; agents distinguish failure types by the status field, not the HTTP code.
SVCB priority 0 is AliasMode (RFC 9460 §2.4) — reserved for v0.2 cross-host delegation. Use priority 1 (ServiceMode) at v0.1. If you accidentally set priority 0, the L0-003 fixture's negative assertion N01 will trip.
In BIND zone-file format, hostnames end with . to indicate fully-qualified-domain-name. So the SVCB TargetName is webgcp.org. (with dot), not webgcp.org (without). Some registrar UIs strip the trailing dot automatically — that's fine; the wire format is unambiguous either way. But if your DoH check returns data: "1 webgcp.org" (no dot), some strict parsers may complain; prefer the dot when entering at the registrar.
A minimal layout that worked for the reference impl:
your-webgcp-impl/
├── README.md
├── docs/
│ └── webGCP-context-integration.md # operator-facing notes
├── public/ # static-published artifacts
│ ├── .well-known/
│ │ └── webgcp # server descriptor (JSON, no ext)
│ ├── webgcp/
│ │ ├── manifests/
│ │ │ └── <kb-id>-v0.1.json
│ │ ├── bundles/
│ │ │ └── <contract>-v0.1.json
│ │ └── conformance/
│ │ └── v0.1/
│ │ ├── runner.py # download from webgcp.org
│ │ ├── WGCP-L0-001.json # copy from webgcp.org
│ │ ├── WGCP-L0-002.json # copy from webgcp.org
│ │ └── WGCP-L0-003.json # copy from webgcp.org
│ └── (rest of your site)
├── services/
│ └── webgcp-query/
│ ├── main.py # ~250-300 lines for L0
│ ├── requirements.txt
│ └── README.md # deploy commands
└── firebase.json (or nginx.conf, k8s yamls, etc.)
Adapt to your stack. The names of public/, services/, etc. don't matter — what matters is the URL paths the published artifacts end up at.
These are one path through L0, not the only path. Substitute freely.
| Concern | Reference impl used | Acceptable substitutes |
|---|---|---|
| Hosting (static) | Firebase Hosting | Netlify, Vercel, Cloudflare Pages, GCS+CDN, S3+CloudFront, GitHub Pages (restricted-domain), self-hosted nginx |
| Hosting (dynamic) | Cloud Function Gen2 (Python) | Cloud Run, AWS Lambda, Vercel Functions, Cloudflare Workers, self-hosted FastAPI/Flask/Express, k8s Pod |
| Language | Python 3.11 | TypeScript, Go, Rust, Java, Elixir — anything that handles HTTP POST + JSON |
| Backing store | In-process fixture dict | Firestore, Postgres+pgvector, BigQuery, Snowflake, DynamoDB, SQLite, files on disk |
| DNS | Squarespace (TXT/A) + Cloudflare migration (SVCB) | Any DNS provider with SVCB support: Cloudflare, Google Cloud DNS, AWS Route 53, NSD/Knot/BIND9 self-hosted |
| Build | Angular ng build (since the site embeds in an existing Angular app) |
Pure static (no build), Eleventy, Astro, Next.js, Hugo |
Cheapest-to-ship combo: Cloudflare Pages (free) + Cloudflare Workers (free L0) + Cloudflare DNS (free SVCB). Likely under 100 lines of TypeScript for the Worker.
For a competent engineer with the host + backing-store decision already made:
| Step | Effort |
|---|---|
| §2.3 Manifest (one KB) | 1–2 hours |
| §2.4 Bundle contract (one type) | 1–2 hours |
| §2.5 Query endpoint | 4–8 hours (most of the work) |
| §2.6 Server descriptor | 30 min |
| §2.7 DNS SVCB | 5 min in UI; up to 30 min if migrating providers |
| §2.8 Run fixtures | 30 min |
| §2.9 Publish + DNS propagation | 30 min – 24 h (mostly waiting) |
Total active engineering: 8–16 hours. Plus DNS propagation latency. A focused weekend gets you to L0.
If you don't have a backing store yet, add 1–2 days to wire something up (or just bundle fixture data for the demo — the-ai-news did this).
If you don't control a domain, add another day for registrar + DNS setup. (Or use a .web.app / similar and accept L0-restricted-domain.)
The standards-playbook clock starts (per establishing-a-standard.md §1, Step 3):
/governance/).L1 work — vector search, multiple primitives, versioned artifacts — is rational only after L0 has soaked. Don't pre-build L1 features into L0.
.well-known/webgcp — spec-host descriptorwebgcporg/site/SVCB-AT-SQUARESPACE.md — registrar-specific SVCB walkthrough (covers fallbacks if your registrar lacks SVCB support)webgcporg/site/DNS-ATTACHMENT.md — initial A/TXT records for first-time domain attachmentestablishing-a-standard.md) — what the soak + second-implementer gates meanNo working group is convened as of 2026-05-16; v0.1 governance is single-author transparency (see /governance/).
To file issues, propose changes, or signal you've shipped:
- Email the contact in https://webgcp.org/.well-known/webgcp (operator + contact fields)
- Once a working group convenes and a GitHub org exists, that becomes the canonical issue tracker (currently pending — see spec §11.3)
Last updated: 2026-05-16. Authored alongside the v0.1 spec publication.