Docs

The package format

A package is one JSON document. It names the site it targets, the tools it registers there, and the site's own HTTP endpoints those tools call. Nothing in it can run arbitrary code in the page: a tool is a declared request, not a script. This page covers every field. When you're ready, paste it into the form.

The shape

Every field below is checked before a package is stored. Validation failures come back as the zod issue list, keyed by the path that failed.

version

integer ≥ 1 · required

Author-declared, not semver. 1 when you create a package; on a new version it must be exactly one higher than the current highest, or the API answers 409 with an expectedVersion. That makes a publish built on a stale copy fail loudly instead of silently replacing a version you never read.
domain

string, 1–253 · required

The lookup key. Lowercased, and a leading www. is stripped. It has to be reachable through urlPatterns. A domain no pattern covers would publish and then never match a page.
urlPatterns

string[], 1–20 · required

Chrome @match-style patterns: scheme://host/path. Scheme is *, http, or https; host is *, *.acme.com (the apex and any subdomain), or an exact hostname; path starts with / and may contain *. Where more than one package matches a page, the more specific pattern ranks higher.
title

string, 1–200 · required

What the registry lists it as.
description

string, 1–5000 · required

What the package does and what it needs (a login session, a particular page). This is what someone reads before deciding to install, so say what the write tools touch.
tools

ToolDescriptor[], 1–30 · required

The tools registered on a matching page. Names must be unique within the package.
api

ApiBlock · optional

The site's HTTP surface. Optional in the schema, required in practice: execution is api-mode only, so a tool that does anything binds to an endpoint declared here.
minEngine

integer ≥ 1 · optional

The capability floor this version needs, like an Android API level. An extension whose own level is lower skips the package instead of half-running it. Current level is 1.
pageType

string, ≤100 · optional

A free-text hint about which kind of page the tools belong to.
changelog

string, ≤2000 · optional

What changed in this version. Shown to installed users deciding whether to move their pin.

Tools

A tool is metadata plus a binding. The description and the input schema are the entire interface an agent sees, so they carry the weight. An agent picks a tool by reading them and nothing else.

name

string, ≤30 · required

Starts with a letter; letters, digits, _ and - after that. Prefix them so they don't collide on a page that has its own tools: notes_search, not search. A tool whose name collides with one the site registered itself is skipped; the rest of the package still registers.
description

string, ≤500 · required

Say what it does, what it returns, and what it needs. Chrome's guidance is the source of the 500, which is enforced here.
inputSchema

JSON Schema object · required

{ type: "object", properties, required? }. properties defaults to {}, so it's effectively optional. At most 20 properties; each takes a type and an optional description (≤1000) and enum, plus nested items/properties/required if you need them. Each property object is loose; unknown keys pass through. Property names are what {{param}} placeholders in the api block refer to.
annotations

object · optional

Hints for the agent: readOnlyHint, untrustedContentHint. Extra keys are allowed and passed through. Mark writes honestly.
execution

{ mode: "api", endpoint } · optional

Binds the tool to a key in api.endpoints. mode is "api", the only mode there is. A tool with no execution is skipped at registration and never reaches the agent.

The api block

This is where the format earns its keep. Instead of shipping a script that pokes at the DOM, you declare the requests the site's own front end already makes, and the extension derives the call. Everything is data, so a reader can audit a package by reading it.

baseUrl

https:// URL · required

Its host must be covered by one of your urlPatterns. That is the same-origin rule: a package can only talk to the site it runs on, with the session the user already has. There is no way to declare a request to somewhere else.
endpoints

Record<string, ApiEndpoint> · required

Named requests. Tools bind to these keys.
auth

Record<string, ApiAuthSource> · optional

Named token flows: fetch a page or endpoint, pull a token out of the response, resend it on the real call.
documents

Record<string, string> · optional

Static GraphQL documents, referenced from an endpoint as "@documents/name". Never scanned for placeholders.

An endpoint

method / path

required

GET, POST, PUT, PATCH, DELETE, and a path resolved against baseUrl.
query

Record<string, string> · optional

Query parameters. Values may carry {{param}}.
body / form / graphql

at most one

body is a JSON template, form is form-encoded fields, graphql is { document, variables }. Declaring two is rejected: the executor would pick one and you'd ship a request you never asked for. A body or graphql.variables leaf that is exactly one {{param}} keeps its type, so a number stays a number.
returns

JMESPath expression · optional

Reshapes the response into what the agent gets back. Compiled at publish time, so a malformed expression is rejected here rather than failing in every user's browser. Project the handful of fields the tool promised; don't hand back the raw payload.
errorPath

string[] · optional

A locator: object keys naming one place in the JSON response, e.g. ["json", "errors"]. A non-empty value there means the call failed, even on a 200. An array, not a dotted string, so a key containing a dot is unambiguous.
stripPrefix

string, ≤50 · optional

A literal prefix removed before the body is parsed as JSON, for sites that armour responses against XSSI, like Google's )]}'. Stripped only when present.
persistedQuery

boolean · optional

Accepted by the schema, but the executor throws on it today; Automatic Persisted Queries aren't implemented yet. Don't set it.
auth

string[], ≤10 · optional

Names of auth sources to attach to this call.

An auth source

source.endpoint

required

Which endpoint to fetch the token from.
source.extract / pattern

exactly one

extract is a locator into the JSON response, e.g. ["data", "modhash"]. pattern is a regex over the raw response text, for tokens served in HTML. Capture group 1 is the token, and no match is a loud failure. Declaring both, or neither, is rejected. Any {{param}} inside pattern is interpolated raw before the regex is compiled, so keep interpolated params identifier-shaped (numeric ids, slugs).
sendAs

{ in, name } · required

Where the token goes: header, form, or query, under name. A form token on an endpoint with no form body is rejected; the request would go out without its credential.
ttlSeconds

integer, 1–86400 · optional

How long a fetched token stays usable. Omit it and the token is re-fetched before every call, which is the safe default.

Placeholders

{{param}} in a path, query value, form field, body leaf, or GraphQL variable is substituted from the bound tool's input. For a tool-bound endpoint, every placeholder must name a property of that tool's inputSchema, or the package is rejected, which also means a typo can't reach a user as an empty string. The same check covers an auth source's fetch endpoint (path/query only) and its pattern; an endpoint no tool binds to is never scanned at all. A query/form/path placeholder for a param the agent didn't supply interpolates to an empty string, while a body/graphql.variables leaf that is exactly one {{param}} yields undefined and drops out of the JSON entirely. There is no escape hatch for a literal {{ yet.

A complete package

Two tools against a site's own JSON API: one read, one write that needs a CSRF token the site hands out on a different endpoint. This document validates: it is parsed against the schema by the test suite, so it can't drift from the format.

{
  "version": 1,
  "domain": "acme.com",
  "urlPatterns": [
    "*://*.acme.com/*"
  ],
  "title": "Example Notes",
  "description": "Search and create notes on acme.com as the logged-in user, through the site's own JSON API. Reads need no token; writes attach the CSRF token the session endpoint hands out.",
  "minEngine": 1,
  "changelog": "Initial version: notes_search, notes_create.",
  "api": {
    "baseUrl": "https://www.acme.com",
    "auth": {
      "csrf": {
        "source": {
          "endpoint": "session",
          "extract": [
            "data",
            "csrfToken"
          ]
        },
        "sendAs": {
          "in": "header",
          "name": "X-CSRF-Token"
        },
        "ttlSeconds": 300
      }
    },
    "endpoints": {
      "session": {
        "method": "GET",
        "path": "/api/session.json"
      },
      "searchNotes": {
        "method": "GET",
        "path": "/api/notes",
        "query": {
          "q": "{{query}}",
          "limit": "{{limit}}"
        },
        "returns": "notes[].{id: id, title: title, updated_at: updated_at}"
      },
      "createNote": {
        "method": "POST",
        "path": "/api/notes",
        "body": {
          "title": "{{title}}",
          "body": "{{body}}"
        },
        "auth": [
          "csrf"
        ],
        "errorPath": [
          "errors"
        ],
        "returns": "note.{id: id, url: url}"
      }
    }
  },
  "tools": [
    {
      "name": "notes_search",
      "description": "Search the logged-in user's notes on acme.com by keyword. Returns the matching notes' ids, titles, and last-updated timestamps, newest first.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "Keywords to search note titles and bodies for"
          },
          "limit": {
            "type": "integer",
            "description": "How many notes to return (1-50)"
          }
        },
        "required": [
          "query"
        ]
      },
      "annotations": {
        "readOnlyHint": true
      },
      "execution": {
        "mode": "api",
        "endpoint": "searchNotes"
      }
    },
    {
      "name": "notes_create",
      "description": "Create a new note on acme.com as the logged-in user. Returns the new note's id and URL.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "title": {
            "type": "string",
            "description": "Note title"
          },
          "body": {
            "type": "string",
            "description": "Note body (plain text)"
          }
        },
        "required": [
          "title",
          "body"
        ]
      },
      "annotations": {
        "readOnlyHint": false
      },
      "execution": {
        "mode": "api",
        "endpoint": "createNote"
      }
    }
  ]
}

The three curated packages in the repository are the same thing against real sites, and cover the awkward cases: Reddit extracts a JSON token into a header, Hacker News scrapes its tokens out of HTML with pattern, Google Maps needs stripPrefix and projects positional arrays. Read them.

Publishing

Paste the JSON or POST /api/packages with a Bearer API key. Same schema either way. Nothing reviews it; it goes live when it validates.

Versions are append-only. A new version is a POST to /api/packages/:id/versions with version set to one above the current highest, and only the owner may publish one. urlPatterns, tools, api, minEngine and changelog are version-scoped and travel with the content. Title, description, domain and pageType are package metadata and are edited in place.

Installs pin to a version, so publishing a new one never moves anybody. They update when they choose to. That cuts both ways: a version you published is a version someone may still be running. What you grant when you publish is worth reading once.