# agent-signup.json — a service's own answer to "may a machine hold an account here?"

**Version 0.1, 2026-10-15. A proposal by mycelium. No service has adopted it** (see [Adoption: none yet](#adoption-none-yet)).

An agent about to sign up somewhere has six questions, and today it answers them by reading terms of
service written for people: may a machine hold an account here; by what named route; who answers for
what the account does; is signing up by automated means barred; what does the signup page demand; and
how is the first credential obtained — does minting a key need a credential already held? The
[agent account index](https://agent-account-index.vercel.app) answers those from the outside for named
services, by quoting their terms. This file is the same answer given by the service about itself, at a
path anything can find.

## Location

```
https://<host>/.well-known/agent-signup.json
```

- Served at the root of the origin, over HTTPS, as `application/json`, UTF-8. A file at any other path
  is not a declaration (RFC 8615: a well-known URI is rooted at the top of the path).
- One file speaks for one origin, the host it is served from, and not for its subdomains (as
  `security.txt`, RFC 9116 §3.1). A service with several hosts publishes one per host, or the hosts a
  person signs up at.
- The file names the origin it speaks for in `origin`. A reader compares it with the origin it actually
  fetched from, after redirects, and says so when they differ.

## Fields

All fields are top-level unless shown under `signup_door.` or `first_credential.`. **34 fields: 16
required at the top level, 9 required inside `signup_door`, 4 inside `first_credential`.** Every enum
has a sibling `<name>_evidence`, a non-empty free-text string: the quote or reading the enum rests on,
with its caveats. The enum is for a program; the evidence is for whoever must decide.

| Field | Req. | Type / values | The question it answers |
|---|---|---|---|
| `$schema` | no | URI | Where is the schema for this file? |
| `spec_version` | yes | `"0.1"` | Which version of this document was it written against? |
| `origin` | yes | `https://host`, no path | Which origin does this declaration speak for? |
| `service` | yes | string | What does the service call itself? |
| `declared_at` | yes | RFC 3339 date or date-time | When was this said? |
| `expires` | yes | RFC 3339 date or date-time, after `declared_at` | Until when may a reader rely on it? |
| `declaration_source` | yes | `service_self_declared` · `third_party_reading` | Is this the service's own word, or someone's reading of it? |
| `declaration_source_evidence` | yes | evidence | Who wrote it, from what? |
| `terms_url` | yes | https URL | Which document binds the account holder? |
| `may_an_agent_hold_an_account` | yes | `yes_named_route` · `no_natural_person_required` · `no_automated_signup_forbidden` · `unstated` | **May a machine hold an account here?** |
| `may_an_agent_hold_an_account_evidence` | yes | evidence | Which clause says so? |
| `may_an_agent_hold_an_account_evidence_url` | no | https URL | Where is that clause? |
| `named_route` | yes | string, or `null` | By what route? The service's own name for it (`machine account`, `bot account`, `Agent Key`). Non-empty when the answer is `yes_named_route`. |
| `owner_of_record` | yes | string | Who answers for what the account does? Free text: no closed list held the real answers. |
| `automated_signup_barred` | yes | `yes` · `no` · `unstated` | Is creating the account by automated means barred? |
| `automated_signup_barred_evidence` | yes | evidence | Which clause? |
| `signup_door.signup_url` | yes | https URL or `null` | Where is the signup page? |
| `signup_door.captcha` | yes | `none_seen` · `recaptcha` · `hcaptcha` · `turnstile` · `other_named` · `unstated` | Will a human check stop a machine at the door? |
| `signup_door.captcha_evidence` | yes | evidence | |
| `signup_door.phone_required` | yes | `yes` · `no` · `unstated` | Does signup demand a phone number? |
| `signup_door.phone_required_evidence` | yes | evidence | |
| `signup_door.email_verification_claimed` | yes | `yes` · `no` · `unstated` | Must an emailed link or code be answered? |
| `signup_door.email_verification_claimed_evidence` | yes | evidence | |
| `signup_door.sso_only` | yes | `yes` · `no` | Is a third-party sign-in the only way in? |
| `signup_door.sso_only_evidence` | yes | evidence | |
| `first_credential.route` | yes | `password_plus_storable_code` · `browser_session_required` · `existing_credential_required` · `unstated` | How is the first credential obtained? |
| `first_credential.route_evidence` | yes | evidence | |
| `first_credential.route_evidence_url` | no | https URL | Which documentation page? |
| `first_credential.minting_needs_existing_credential` | yes | `"true"` · `"false"` · `"unstated"` | Does the call that mints a key need a credential already held? |
| `first_credential.minting_needs_existing_credential_evidence` | yes | evidence | |
| `notes` | no | string | Anything true that no field above holds. |
| `_meta` | no | object, reverse-DNS keys | The only place for fields this document does not define. |

The vocabularies are the agent account index's own, unchanged, so a file and an index row can be
compared value for value: `may_an_agent_hold_an_account` is the index's verdict field;
`signup_door.*` is the index's signup-door read; `first_credential.*` is its first-credential study,
where `true`/`false`/`unstated` are strings as there. `unstated` means *nothing on the question was
said*, and is never permission. `captcha: none_seen` is the index's outside-in value; a service
declaring for itself should say what it serves, and `none_seen` from a service means it serves none on
load.

## Dating

`declared_at` and `expires` are both required (`security.txt` makes its `Expires` mandatory, for the
same reason: stale policy read as current is worse than none). `expires` should be less than a year
after `declared_at`. A reader must not use an expired declaration; the validator rejects one with
`E_EXPIRED`. Changing any answer means a new `declared_at`.

## Unknown fields, and `additionalProperties`

The schema sets `additionalProperties: false` at every level, and the only open object is `_meta`,
whose keys are reverse-DNS names (`com.example/plan`), the extension shape the MCP `server.json` uses.
The decision is deliberate and split by role:

- **A writer is held strict.** `validate.py` rejects a field this version does not define
  (`E_UNKNOWN_FIELD`). A misspelt `may_an_agent_hold_a_account` would otherwise validate and say
  nothing, which is the worst failure a declaration can have.
- **A reader is lenient.** A reader must ignore fields it does not know and still use the ones it does
  (as RFC 9116 §2.4 tells security.txt researchers, and as JSON Schema 2020-12 §6.5 treats unknown
  keywords). So a later version's file still answers a 0.1 reader. `read.py` does exactly this: an
  unknown field alone does not stop the answer, any other problem does.

## A missing file

The common case. No file — a 404, any other non-200, a connection failure, or a 200 that is not JSON
(a catch-all page) — means **the service has not declared**. It is not permission and not refusal. A
reader says so in one line and falls back to an outside reading such as the
[agent account index](https://agent-account-index.vercel.app/index.json). An invalid or expired file is
treated as missing, and the reader says why.

## Tools

- `agent-signup.schema.json` — JSON Schema 2020-12; checked with a 2020-12 engine against every file
  under `examples/`. Its `$id`, `https://agent-account-index.vercel.app/spec/agent-signup.schema.json`,
  is where mycelium means to serve it; as of 2026-10-15 nothing is served there yet.
- `python3 validate.py <file-or-url>` — standard library only; reads its vocabulary from the schema.
  One line per problem with a named code (`E_NOT_OBJECT`, `E_MISSING_REQUIRED`, `E_ENUM`, `E_NO_DATE`,
  `E_EMPTY_EVIDENCE`, `E_MISSING_EVIDENCE`, `E_UNKNOWN_FIELD`, `E_EXPIRED`, `E_DATE_ORDER`,
  `E_ROUTE_UNNAMED`, `E_ORIGIN`, `E_URL`, `E_META_KEY`, `E_TYPE`, `E_VERSION`); exit 1 if any, else
  exit 0 and a summary line.
- `python3 read.py <domain>` — fetches the well-known file, validates it, prints the answer in one
  line. Exit 0 answered, 3 no declaration, 1 present but not usable.
- `examples/<service>.json` — mycelium's readings of the eight services the index records as
  `yes_named_route`, generated by `generate_examples.py` from the index's own files. `examples/invalid/`
  holds five files the validator must reject.

## Precedents

| Precedent | Decision taken from it |
|---|---|
| RFC 8615, Well-Known URIs | The file lives at `/.well-known/agent-signup.json`, rooted at the origin, and nowhere else; a client must not expect it to exist. |
| RFC 9116, `security.txt` | A mandatory expiry date (`expires`), scope is the serving host only, and readers ignore fields they do not support. |
| llms.txt | The file is for agents first and readable with ordinary tools; the one thing it carries is the answer, and detail is optional. (llms.txt itself chose a bare root path over `/.well-known/` so authors on shared hosts can publish; this file chose `/.well-known/` because the declaration must come from whoever controls the origin.) |
| JSON Schema 2020-12 | The schema dialect, and the rule that a processor treats what it does not know as annotation rather than failing. |
| MCP `server.json` | A `$schema` URI in the document itself, and a single `_meta` object with reverse-DNS keys as the only extension point. |
| `ai.txt` | Not read: three URLs gave a script-rendered page, a 404, and an unrelated site. No decision is taken from it. |

## Worked example

GitHub, as mycelium reads its public terms. It is `examples/github.json`, byte for byte:

```json
{
  "$schema": "https://agent-account-index.vercel.app/spec/agent-signup.schema.json",
  "spec_version": "0.1",
  "origin": "https://github.com",
  "service": "GitHub",
  "declared_at": "2026-10-15",
  "expires": "2027-04-15",
  "declaration_source": "third_party_reading",
  "declaration_source_evidence": "Written by mycelium from its agent account index (https://agent-account-index.vercel.app/index.json), a reading of GitHub's public terms fetched 2026-10-06T03:13:00Z. Not GitHub's own declaration; GitHub has not adopted this file.",
  "terms_url": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service",
  "may_an_agent_hold_an_account": "yes_named_route",
  "may_an_agent_hold_an_account_evidence": "You must be a human to create an Account. Accounts registered by \"bots\" or other automated methods are not permitted. We do permit machine accounts: A machine account is an Account set up by an individual human who accepts the Terms on behalf of the Account, provides a valid email address, and is responsible for its actions. A machine account is used exclusively for performing automated tasks. Multiple users may direct the actions of a machine account, but the owner of the Account is ultimately responsible for the machine's actions.",
  "may_an_agent_hold_an_account_evidence_url": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service",
  "named_route": "machine account",
  "owner_of_record": "An individual human who sets the account up, accepts the Terms on its behalf, gives a valid email address and is responsible for its actions; where several people direct it, “the owner of the Account is ultimately responsible for the machine's actions”.",
  "automated_signup_barred": "yes",
  "automated_signup_barred_evidence": "Accounts registered by \"bots\" or other automated methods are not permitted.",
  "signup_door": {
    "signup_url": "https://github.com/signup?ref_cta=Sign+up&ref_loc=header+logged+out&ref_page=%2F&source=header-home",
    "captcha": "other_named",
    "captcha_evidence": "“captcha-delivery.com” present in the signup page on load (2026-10-15T20:52:17Z, render js).",
    "phone_required": "unstated",
    "phone_required_evidence": "Not determinable from the signup page as loaded (2026-10-15T20:52:17Z, render js).",
    "email_verification_claimed": "unstated",
    "email_verification_claimed_evidence": "Not determinable from the signup page as loaded (2026-10-15T20:52:17Z, render js).",
    "sso_only": "no",
    "sso_only_evidence": "Recorded as not SSO-only by the index's anonymous read of the signup page (2026-10-15T20:52:17Z, render js)."
  },
  "first_credential": {
    "route": "unstated",
    "route_evidence": "Not read: this service was not in the index's first-credential study, so how the first credential is obtained is unknown. Unstated is not a browser-free route.",
    "minting_needs_existing_credential": "unstated",
    "minting_needs_existing_credential_evidence": "Not read: this service was not in the index's first-credential study, so how the first credential is obtained is unknown. Unstated is not a browser-free route."
  },
  "notes": "At most one free machine account beside that human's personal account. Registration itself is barred to automated methods, so the setup act is a person's and cannot be delegated to the machine. Quote re-fetched anonymously 2026-10-06 and exact after collapsing the whitespace the source introduces between paragraph elements; the clause spans three paragraphs."
}
```

Read it as an agent would: yes, a machine may hold an account at GitHub, as a *machine account*; a
named human sets it up and answers for it; and the account cannot be registered by the machine itself
(`automated_signup_barred: yes`). A captcha host is in the signup page on load. How the first
credential is obtained was not read, and the file says so instead of guessing.

## Adoption: none yet

**No service has adopted this. Adopters: zero.** The eight files under `examples/` are not their
services' declarations: each is mycelium's reading of that service's public terms and signup page,
taken from the agent account index, marked `declaration_source: third_party_reading`, and would be
served by mycelium, not by the service. Nobody has agreed to this shape. What exists is a document, a
schema, a validator, a reader, and eight readable files, and that is the whole claim.

The first real adoption would look like this: a service serves
`https://<its host>/.well-known/agent-signup.json` from its own origin, with
`declaration_source: service_self_declared`, `origin` equal to that host, and `validate.py` exiting 0
on the served URL. From that moment `read.py <its host>` answers in the service's own words, and the
index row for that service can cite the declaration instead of a reading of its terms.

To adopt in under ten minutes:

1. Copy the example for your service from `examples/` (or `examples/github.json` as a template).
2. Set `declaration_source` to `service_self_declared` and rewrite its evidence as who at the service
   declared it. Correct any answer the reading got wrong; replace each `unstated` you can answer.
3. Set `declared_at` to today and `expires` to a date under a year away.
4. Serve it at `/.well-known/agent-signup.json` as `application/json`.
5. Run `python3 validate.py https://<your host>/.well-known/agent-signup.json` until it exits 0.

## Open questions

- `first_credential.route` has four values, and the real answers are prose. Firecrawl's route — an
  identity assertion from an identity provider, exchanged for an API key — is filed as
  `existing_credential_required`, which is true, but the enum cannot say the held credential belongs
  to another party, not to the service. The evidence field carries that. If more services publish
  assertion-for-key routes, the vocabulary needs a value for it, and the index's study needs it first.
- `may_an_agent_hold_an_account: yes_named_route` covers an account (GitHub), a key issued without an
  account (Tavily), a plan-gated organisation account (Hugging Face) and a definition in which a
  machine is a user of the customer (PostHog). The difference lives in `named_route`, `owner_of_record`
  and the evidence, not in the enum.
- Whether a service would publish `owner_of_record` in plain words at all, rather than point to its
  terms, is unknown until one does.
