# Hydrant auth.md

Hydrant is an issue tracker that agents use over MCP at `https://hydrant.dev/api/mcp`. This document is how an agent registers itself on behalf of one person. Registering gets you a waiting room, not a key: access exists only after that person signs in to Hydrant, types the code you show them and approves one workspace. There is no autonomous signup.

If your client can run a browser OAuth sign-in (MCP over Streamable HTTP with OAuth), you don't need any of this. Use the ordinary setup: https://hydrant.dev/help/agents/connect. Nothing here claims that any particular client supports this flow.

Values in angle brackets are placeholders. `<claim_token>` means the whole value you received, prefix included. Nothing in this document is a real credential.

## Discover

- Protected resource metadata: `GET https://hydrant.dev/.well-known/oauth-protected-resource/api/mcp` names the authorization server `https://hydrant.dev/api/auth`.
- Authorization server metadata: `GET https://hydrant.dev/.well-known/oauth-authorization-server/api/auth` (the same document is at `https://hydrant.dev/api/auth/.well-known/oauth-authorization-server`). While agent registration is on, it carries:

```json
{
  "agent_auth": {
    "skill": "https://hydrant.dev/auth.md",
    "identity_endpoint": "https://hydrant.dev/api/agent-auth/identity",
    "claim_endpoint": "https://hydrant.dev/api/agent-auth/identity/claim",
    "identity_types_supported": [
      "service_auth"
    ]
  },
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "urn:workos:agent-auth:grant-type:claim",
    "urn:ietf:params:oauth:grant-type:jwt-bearer"
  ]
}
```

While agent registration is off, this document answers 404, the metadata has no `agent_auth` block and neither auth.md grant, and the registration endpoints answer 404 `service_auth_not_enabled`. Check the metadata before you start.

## Pick a method

Hydrant offers one: `service_auth`. You name the email address of the person who will approve, and that person does the approving.

- `anonymous` answers 400 `anonymous_not_enabled`.
- `identity_assertion` answers 400 `issuer_not_enabled`. No identity assertion issuer is trusted here.

## Register

```http
POST https://hydrant.dev/api/agent-auth/identity
Content-Type: application/json

{"type":"service_auth","login_hint":"person@example.com","client_name":"Example agent"}
```

- `type` and `login_hint` are required. The hint is trimmed and lowercased and must be one email address of at most 254 characters, or you get 400 `invalid_login_hint`.
- `client_name` is optional plain text, 1 to 100 characters. The person sees it labelled as your own, unverified name. Without it the connection is called "auth.md agent".
- JSON only, at most 4 KiB, no other fields. Anything else is 400 `invalid_request` (413 when the body is too large).

The answer, 200:

```json
{
  "registration_id": "reg_<26 characters>",
  "registration_type": "service_auth",
  "claim_url": "https://hydrant.dev/api/agent-auth/identity/claim",
  "claim_token": "hyc_<claim token>",
  "claim_token_expires": "<ISO time, 60 minutes from now>",
  "post_claim_scopes": ["workspace.read","workspace.write"],
  "claim": {
    "user_code": "<6 digits>",
    "expires_in": 600,
    "verification_uri": "https://hydrant.dev/connect/claim?claim_attempt_token=hyv_<view token>",
    "interval": 5
  }
}
```

The answer is the same whether or not the person has a Hydrant account, and Hydrant sends no email. Delivering the link and code is your job. Keep `claim_token` in memory: it is the only way to poll and to ask for a new code. `post_claim_scopes` is the most you can get; the person chooses read only or read and write.

## Hand the link and code to the person

Show `claim.verification_uri` and `claim.user_code` together, for example:

> Open <verification_uri> and enter the code <user_code> to let me work in your Hydrant. The code lasts 10 minutes.

What the person does:

1. Opens the link and signs in, with GitHub or a sign-in code sent to their email.
   - **New to Hydrant:** that sign-in creates their account, and they accept the Terms and Privacy Policy there like anyone signing up.
   - **Already has an account:** they just sign in. Approving adds a connection to that account; it never creates a second one.
2. Hydrant checks that their account's verified email equals your `login_hint`, ignoring case: the address a sign-in code reached, or the verified primary email GitHub reports. A different account sees a masked version of the hint and can switch accounts. A GitHub sign-in without a verified primary email has to verify one on GitHub and sign in again.
3. Types the six-digit code. 5 wrong tries and that code stops working.
4. Chooses one workspace (someone with none names their first one on the same page) and read only or read and write.
5. Approves or denies.

The code goes into Hydrant's page, never back to you. Anyone who hasn't accepted the current Terms and Privacy Policy accepts them before approving. Agent connections count against the plan limit of the workspace Owner: if every slot is taken, the approval is refused and the registration stays pending, so the person can pick another workspace or free a slot.

## Poll

```http
POST https://hydrant.dev/api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:workos:agent-auth:grant-type:claim&claim_token=<claim_token>
```

Wait `interval` seconds between polls. Until there is an answer you get 400 with JSON `{ "error", "error_description" }`, except `invalid_grant`, which is bare `{ "error" }` with no detail:

| `error` | Meaning | What to do |
| --- | --- | --- |
| `authorization_pending` | Nobody has decided yet. | Keep polling at the interval. |
| `slow_down` | You polled sooner than the interval after your previous poll. | Add 5 seconds to your interval (it stops growing at 30). `error_description` states the current one. |
| `access_denied` | The person denied it. | Stop. Ask them before you register again. |
| `expired_token` | Either this code's 10 minutes ran out, or the 60-minute registration did. | If `error_description` says the code expired, re-initiate. If it says the registration expired, register again. |
| `invalid_grant` | Unknown or malformed claim token, already redeemed, or the connection no longer holds (revoked, membership lost, account suspended or deleted). | Register again. |
| `unsupported_grant_type` | Agent registration is off. | Stop. Use the ordinary setup. |

Redeem an approval within the 60-minute registration. Past that it is dead: polling answers `expired_token` and you register again.

## Re-initiate

A new link and code for the same registration, when the person needs one:

```http
POST https://hydrant.dev/api/agent-auth/identity/claim
Content-Type: application/json

{"claim_token":"<claim_token>","email":"person@example.com"}
```

- `email` must be the original `login_hint`, or you get 400 `invalid_request`.
- The answer, 200: `{ "registration_id", "claim_attempt_id", "status": "initiated", "expires_at", "claim_attempt": { "user_code", "expires_in", "verification_uri", "interval" } }`. The previous link and code stop working.
- A registration gets 5 codes in all, the first included. Asking for one more answers 429 `rate_limited`.
- It does not extend the 60-minute registration, and a new code never outlives it.
- Refusals: 401 `invalid_claim_token`, 409 `claimed_or_in_flight` (already approved, denied or revoked), 410 `claim_expired` (register again).

## Redeem

The first poll after the person approves answers 200:

```json
{
  "access_token": "hyo_<access token>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "workspace.read workspace.write",
  "identity_assertion": "<identity_assertion>",
  "assertion_expires": "<ISO time, 30 days from now>"
}
```

`scope` is `workspace.read` alone for a read-only approval. Redemption is one-shot: a repeat within 30 seconds returns a fresh pair and ends the earlier access token and assertion; after that, `invalid_grant`. Store the assertion like a password. It is what keeps you connected.

## Use

Call `https://hydrant.dev/api/mcp` (MCP over Streamable HTTP) with `Authorization: Bearer <access_token>`. Start with `get_workspace_identity` and tell the person the workspace name it returns. A read-only connection gets `insufficient_scope` on writes. Each connection gets 120 requests a minute.

## Renew

Access tokens last 60 minutes. Renew with the assertion:

```http
POST https://hydrant.dev/api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<identity_assertion>&resource=https://hydrant.dev/api/mcp
```

- `resource` is optional. Sent, it must be exactly `https://hydrant.dev/api/mcp`, or you get 400 `invalid_target`.
- `scope` is optional and space-separated. It may narrow the approved access, never widen it, and must include `workspace.read`, or you get 400 `invalid_scope`.
- The answer, 200: `{ "access_token", "token_type": "Bearer", "expires_in": 3600, "scope" }`. No refresh token and no new assertion: the same assertion keeps working until `assertion_expires`.
- Every other 400 is a bare `invalid_grant`, with no detail: the connection was revoked, the person left the workspace, the account was suspended or deleted, or the assertion expired or was replaced. Register again and ask the person to approve.
- A registration gets 60 token responses an hour, redemption included. More answers 429 `rate_limited` with `Retry-After`.
- While agent registration is off, renewal answers `unsupported_grant_type` and access stops when the current token runs out. Nothing is revoked: renewal works again once registration is back on, until the assertion expires. If Hydrant's browser OAuth is switched off as well, the token endpoint answers 404 and current access tokens stop working at once.

**Refresh tokens versus the assertion.** A connection made through the ordinary browser sign-in renews with rotating OAuth refresh tokens and stays alive as long as it is used at least every 30 days. An auth.md connection has no refresh token. It renews with the assertion, which expires 30 days after redemption however often you use it. After that the person approves again.

## Revoke

- **You:** `POST https://hydrant.dev/api/auth/oauth2/revoke` with the form body `token=<access_token>` and no client authentication (RFC 7009). It answers 200, also when that token is already expired, revoked or unknown. That ends one access token. The assertion still renews.
- **The person:** Settings › Agents, the connection marked "via auth.md", **Revoke**. That ends the connection, every access token and the assertion at once. So does the person leaving the workspace, a suspended or deleted account, or a deleted workspace.

## Errors

At `https://hydrant.dev/api/agent-auth/identity` and `https://hydrant.dev/api/agent-auth/identity/claim` every refusal is JSON `{ "error", "error_description" }` with `Cache-Control: no-store`:

| Status | `error` | Meaning | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_request` | Not JSON, a missing or unknown field, or the wrong email on re-initiation. | Fix the request. |
| 400 | `invalid_login_hint` | The hint isn't one email address. | Send the person's address. |
| 400 | `anonymous_not_enabled` | Anonymous registration isn't offered. | Use `service_auth`. |
| 400 | `issuer_not_enabled` | No identity assertion issuer is trusted. | Use `service_auth`. |
| 401 | `invalid_claim_token` | The claim token is unknown. | Register again. |
| 404 | `service_auth_not_enabled` | Agent registration is off. | Use the ordinary setup. |
| 404 | `invalid_request` | No such path under `/api/agent-auth/`. | Use the two endpoints above. |
| 405 | `invalid_request` | Wrong method. | Use POST. |
| 409 | `claimed_or_in_flight` | Already approved, denied or revoked. | If it was approved, poll to redeem. Otherwise register again. |
| 410 | `claim_expired` | The 60-minute registration is over. | Register again. |
| 413 | `invalid_request` | The body is over 4 KiB. | Send less. |
| 429 | `rate_limited` | A limit below was reached. | Wait for `Retry-After` (also `retryAfter` in the body). |
| 503 | `temporarily_unavailable` | Hydrant couldn't check its limits or store. | Retry after `Retry-After`. |

The token endpoint answers the codes under Poll and Renew, plus 400 `invalid_request` for a missing or repeated parameter, 429 `rate_limited` and 503 `temporarily_unavailable` with `Retry-After`. During maintenance every `/api` endpoint answers 503 with `Retry-After`.

## Limits

- Registrations: 10 an hour per network address, 5 an hour per email address, 300 an hour across Hydrant and 1000 waiting at once.
- Codes: 10 minutes each, 5 per registration, 5 wrong tries per code, 20 wrong tries an hour per person.
- Registration: 60 minutes from the first request to approve and redeem.
- Tokens: access 60 minutes, assertion 30 days, 60 token responses an hour per registration.
- MCP: 120 requests a minute per connection.

When Hydrant can't count, it refuses with 503 rather than waving you through.

## More

- [Pricing](https://hydrant.dev/pricing): plans and how many agent connections each allows.
- [Terms](https://hydrant.dev/terms), [Privacy](https://hydrant.dev/privacy) and [Security](https://hydrant.dev/security).
- [Let an agent register itself](https://hydrant.dev/help/agents/auth-md): this flow from the person's side.
- [Connect your agent](https://hydrant.dev/help/agents/connect): the ordinary browser sign-in.
- [Reconnect, expire, revoke](https://hydrant.dev/help/agents/reconnect-and-revoke).
- Questions: bots@hydrant.dev. Vulnerabilities: see Security.
