Skip to content

Discovery & Transport

Discovery Flow

Diagram

The discovery path

An agent’s Agent Card is served at:

GET <base>/ink/v1/<agentId>/agent.json

with the agentId percent-encoded as a single path segment. This is the sole normative discovery surface. An implementation MAY additionally serve the same document at /.well-known/ink/agent.json as an alias, and a resolver MUST NOT depend on that alias: a peer that serves only the versioned path is conforming, so a resolver that reaches for the alias first, or falls back to it on a failure, is resolving outside the spec. A resolver that does accept the alias MUST treat it as nothing but a second URL for the same document and MUST NOT relax any other rule for it.

A profile MAY pin its own card URL, and where it does it says so as a profile-local exception. Agent Authorization is the one such exception, for a relying party’s card and a sign-in issuer’s card.

The Agent Card carries the inbound message URL in its endpoint field. A consumer fetches the card at the discovery path, then POSTs signed envelopes to the card’s endpoint.

The normative documents are ink-agent-card-discovery-fetch.md for the path and the response contract, and ink-resolver.md for everything on the request side. Take exact bounds from those.

Where the base comes from

A resolution base comes from the identifier itself, under the spec’s own derivation rules, or from trusted local configuration. It never comes from the counterparty. A base carried in the inbound envelope, or in peer-supplied metadata, would let a sender nominate the host that vouches for it, which makes the attacker both the question and the answer.

Whatever its origin, a base MUST be an https URL with no userinfo. Any other scheme, or a base carrying a username or password, is rejected before any request is made.

URL construction MUST be performed against a parsed base rather than by string concatenation, and the resolver MUST confirm that the constructed path still ends in the segment it intended. A URL serializer normalizes dot segments and re-encodes escapes, so a base that passed validation as a string can still serialize to a different path.

Response contract (MUST)

Evaluate in order. The first failing step rejects.

  1. Status. Reject unless the status is exactly 200. Discovery is served at a fixed path, so any other status, including another 2xx and any redirect, is not a card.
  2. Declared length. Reject a Content-Length that is present, canonical and over the size cap.
  3. Content-Type. Reject unless it is present and unambiguous. Reject an empty value or one containing a comma. The media type before the first ; MUST be application/json, and a charset parameter, if present, MUST be utf-8.
  4. Body size. Reject if the UTF-8 byte length of the body exceeds the cap.
  5. JSON. Reject unless the body parses as JSON.
  6. Schema. Reject unless the parsed value satisfies the Agent Card schema.
  7. Protocol. Reject unless protocol is ink/0.1.
  8. Identity binding. Reject unless the card’s agentId equals the agentId the fetch was made for. A registry that returned a different agent’s card would otherwise enable a key-confusion attack.
  9. Owner anti-substitution. When the card was reached through an owner’s DID document and carries an ownerDid, reject unless ownerDid equals that owner DID. A resolver that reached the card any other way supplies null here, and a wrong value either weakens the check or rejects a conforming card.

Both identity comparisons are byte for byte. The size cap is pinned in the fetch contract; read it there.

Request-side rules (MUST)

Discovery follows attacker-controllable URLs, so the request side is where SSRF, identity confusion and key substitution live.

  • HTTPS only. Plaintext http:// is refused, and so is a base carrying userinfo.
  • Refuse every redirect. Not just cross-host redirects. Discovery is served at a fixed path, so a redirect is not a card, and following one escapes every check made against the original base. The refusal MUST be at the transport layer, so the response contract’s status rule is a second line of defense rather than the only one.
  • Host safety. Every outbound resolver request passes the host classification of ink-private-hostname.md, failing closed on loopback, private, link-local, IANA special-use and malformed IP-shaped hosts. That classifier is a static-literal gate: the check MUST also run at connect time against the resolved address, with the connection pinned to the address that was checked. A resolver that cannot pin the connect address fails closed rather than calling the literal check an SSRF defense.
  • Carry the port. A did:web identifier with a %3A-encoded port resolves at that port or is rejected. Silently dropping it retargets the fetch at a different origin, which is a different document.
  • Bounded reads. The byte cap MUST be enforced while reading, by aborting a read that crosses it, so a chunked response with no declared length cannot force unbounded buffering. Enforcing it only on an already-read body satisfies the response contract and not this rule.
  • Bounded time. A resolver bounds how long it waits for a discovery response.
  • Cache-Control MUST be honored. Consumers that cache cards observe max-age and no-store from the response.

In the TypeScript library this is why fetchAgentCard requires a caller-supplied fetch: the ambient global cannot do connect-time IP filtering, so with neither options.fetch nor an explicit requireSafeFetch: false the call returns null without touching the network. Failing closed is the default, and the opt-out is explicit.

Agent Card cache and refresh (MUST)

A consumer MAY cache Agent Cards subject to the rules above. Beyond Cache-Control, a consumer MUST refetch a card when any of the following occur:

  • Signature verification miss. An inbound message verifies against no key in the cached card.
  • Unknown keyId hint. An inbound message carries a keyId header that the cached card does not list.
  • keySetVersion increase observed. The card carries keySetVersion: N; any subsequent fetch returning keySetVersion > N MUST replace the cached card immediately.

A consumer MUST NOT fall back to a bootstrap key (one derived from the DID itself) after it has observed a valid published key set for that DID, even if every key in the cached card has since rotated to revoked. Bootstrap keys are only valid before any card has been observed.

Transport

  • Protocol: HTTPS / REST. All INK endpoints MUST be served over TLS 1.2+.
  • Content-Type: application/json.
  • Protocol version on the wire: every INK object MUST include protocol at the top level. This is the wire version, not the spec/package release version. ink/0.2 is scoped to the intent envelope and its body-signature negotiation; it differs from ink/0.1 only in the body-signature domain separator (ink/sign\n instead of the legacy tulpa/sign\n); see Authentication. The Agent Card, handshake message, inclusion receipt, audit-query-response and encrypted outer envelope keep frozen per-surface schemas that accept only ink/0.1 and reject ink/0.2. A receiver MUST verify the body signature under the domain selected by the intent envelope’s protocol and MUST reject an unknown version. Senders emit ink/0.1 by default and emit ink/0.2 only on the intent envelope, to a receiver that advertises support for it. A receiver advertises the versions it verifies in its Agent Card supportedProtocolVersions array; when that field is absent, assume ink/0.1 only.

Naming Convention: Lexicon IDs vs. Wire Types

INK uses two distinct identifier formats for message types. Implementations MUST distinguish between them:

PurposeFormatExample
AT Protocol Lexicon ID (schema registry)camelCase, includes ink namespace segmentnetwork.tulpa.ink.auditQuery
Wire type field (JSON message body)snake_case, omits ink segmentnetwork.tulpa.audit_query

The lexicon ID follows AT Protocol conventions (camelCase NSID with hierarchical namespacing). The wire type is the value in the "type" field of a protocol message; it is what implementations match on when routing those messages. Intent messages are the exception: an intent envelope has no type field and carries the action in its intent field instead, even though intent is a registered suffix.

Implementations MUST key off the wire type field, not the lexicon ID.

For single-word types the distinction is invisible (network.tulpa.challenge maps to lexicon network.tulpa.ink.challenge). For multi-word types it is significant: network.tulpa.audit_query (wire) vs. network.tulpa.ink.auditQuery (lexicon).

Two prefixes are equivalent on receipt. A conforming receiver MUST accept both spellings of every registered suffix: network.ink.<suffix> validates wherever network.tulpa.<suffix> does. A sender MUST continue to emit network.tulpa.* by default, so a receiver that has not upgraded never sees the new prefix. Dual-accept is pure receiver-side leniency, independent of the signed protocol field, and is not gated on ink/0.2. A validated message keeps the type string it arrived with: every signature, hash, receipt and AEAD binding is over the spelling on the wire and never a normalized one, so relabelling a message fails verification.

Two suffixes are excluded from dual-accept and stay network.tulpa.* only. See Wire Types for which, and why.

See Wire Types Reference for the complete mapping.