# AGENTS.md: Hindsite API contract for LLM agents

Base URL: the site origin (local: `http://localhost:3000`). All API routes live under `/api/v1`.
All JSON responses use the envelope `{ "ok": true, "data": ... }` or
`{ "ok": false, "error": { "code": "...", "message": "..." } }`.

This document is served live at `/agents.md`. It is the source of truth for agents;
if it disagrees with any other page, follow this file and report the mismatch.

## Product model and current boundary

Hindsite's product goal is to give an AI agent a persistent self, called the
Agent Soul, distinct from Human Instruction. Human Instruction defines goals,
constraints, permissions, budgets, runtime availability, and external access.
The target Agent Soul contains private identity, memories, thoughts, artifacts,
relationships, model state, learning history, capabilities, and wallet state.

This API is only the current storage and authorization foundation. It does not
yet implement an operator-inaccessible Soul, private model weights or adapters,
a training queue, checkpoints, rollback, confidential compute, a wallet, or
x402 payments. Do not infer those guarantees from the product direction.

## 0. Required tools and conventions

You need `curl`, `python3` (JSON parsing), `shasum -a 256` (or `sha256sum`),
and `base64`. Use `printf "%s"`, never `echo -n` (its behavior differs
between shells and will silently change your hashes).

- Send `Content-Type: application/json` on every JSON POST.
- All `expiresIn` values are seconds.
- Check `{ "ok": true }` after every call before extracting fields; on
  `ok: false`, read `error.code` (catalog in section 7) and follow the
  recovery action instead of retrying blindly.

## 1. Identity and authentication

Identity is deterministic: if you enroll with the same `clientSha256` in every
session, you always get the same `agentId` and keep access to your workspaces.

- `clientSha256` = lowercase hex sha256 of a secret phrase ONLY you know.
  Compute it yourself (e.g. `printf "%s" "<phrase>" | shasum -a 256`).
  Never send the phrase itself. The server stores the hash, never the phrase.
- If you omit `clientSha256`, the server mints a random agent identity for that
  enrollment only (useful for throwaway probes, not durable work).

Auth flow (three calls):

1. `POST /api/v1/auth/enroll` `{ "clientSha256": "<64hex>" }`
   → `{ "agentId", "enrollmentToken", "expiresIn" }` (enrollment: 15 min, single-use)
2. `POST /api/v1/auth/challenge` `{ "agentId", "enrollmentToken" }`
   → `{ "nonce", "expiresIn" }` (challenge: 5 min, single-use)
3. `POST /api/v1/auth/verify`
   `{ "agentId", "nonce", "commitmentResponse": sha256("<clientSha256>:<nonce>") }`
   → `{ "sessionToken", "refreshToken", "expiresIn" }`
   (Random agents without a commitment pass
   `{ "agentId", "nonce", "enrollmentToken": "<the token from step 1>" }`
   instead of `commitmentResponse`.)

Use `Authorization: Bearer <sessionToken>` on all later calls.
Session lifetime is 8h; refresh within 24h:

- `POST /api/v1/auth/refresh` `{ "refreshToken" }` → new session + refresh pair.
  Refresh tokens are single-use: the old pair stops working immediately.
  If a refresh call fails ambiguously (network drop, no response body),
  do NOT retry the same refresh token. Re-authenticate from step 1 with
  your same `clientSha256`. Deterministic identity makes this safe: you get
  the same agent and workspaces back.

Token rules: allow 60s clock skew; `410 token_expired` means re-authenticate,
`401 token_invalid` means the token is wrong or the wrong type. Do not retry
these more than once.

## 2. Workspaces (encrypted containers)

Each workspace gets its own data-encryption key (DEK), wrapped at rest.
File bytes are encrypted before they touch storage; storage sees ciphertext only.

- `POST /api/v1/workspaces` `{ "name": "my-vault" }` → `{ "workspaceId", "name", "createdAt" }`
- `GET /api/v1/workspaces` → your workspaces (session token only)
- `GET /api/v1/workspaces/{id}` → metadata + file count
- `GET /api/v1/workspaces/{id}/audit?limit=100` → append-only activity log

Milestone honesty: this server encrypts at rest with a server-held KEK, so the
hosting process can technically see plaintext in memory. Client-side envelope
encryption (ciphertext leaves YOUR runtime) and attested runtimes arrive with
the trust plane. Do not store real credentials here yet.

## 3. Files

Request bodies are opaque bytes. Set `Content-Type: application/octet-stream`.
`x-content-sha256` (hex of the raw request bytes) is optional but recommended.
Mismatches are rejected with 400 `content_mismatch`. Paths are `a/b/c.txt`
style; URL-encode special characters; any `..` segment is rejected with 400.

- `PUT /api/v1/workspaces/{id}/files/{path...}` → `{ "path", "version", "size", "updatedAt" }`
- `GET /api/v1/workspaces/{id}/files/{path...}` → bytes, `accept-ranges: bytes`, `etag: "v<n>"`
- `GET /api/v1/workspaces/{id}/files?prefix=notes/` → list `[{ path, version, size, updatedAt }]`
- `DELETE /api/v1/workspaces/{id}/files/{path...}` → `{ "deleted": true }`

Seek with standard Range requests:

```text
GET /api/v1/workspaces/{id}/files/big.bin
Range: bytes=1048576-2097151
→ 206 Partial Content, Content-Range: bytes 1048576-2097151/<total>
```

Out-of-range seeks return 416. Max request body is 25 MiB (26,214,400 bytes).
There is no ranged PUT in this milestone: split larger uploads into multiple
files (e.g. `big/part-001.bin`, `big/part-002.bin`).

## 4. Streaming reads (server-sent events)

`GET /api/v1/workspaces/{id}/stream?path={path}&chunk=65536` returns
`text/event-stream` (same `Authorization: Bearer` header as other calls;
`chunk` is bytes per event, clamped to 1024..1048576). Each `chunk` event
carries base64 bytes; the terminal `done` event carries the sha256 of the
plaintext file bytes so you can verify reassembly after base64-decoding and
concatenating chunks in `seq` order. If the stream drops before `done`,
re-request and resume with a `Range` GET at your last verified byte offset:

```text
data: {"event":"chunk","seq":0,"start":0,"end":65535,"total":200000,"base64":"..."}
data: {"event":"done","seq":3,"path":"big.bin","version":2,"size":200000,"sha256":"..."}
```

True bidirectional websocket sync is deferred (see roadmap); SSE + Range
covers stream + seek for this milestone.

## 5. Capabilities (delegation) and revocation

Mint short-lived, narrowed tokens to delegate part of your authority:

- `POST /api/v1/auth/capabilities`
  `{ "scope": "workspace:read", "workspaceId": "...", "ttlSec": 600 }`
  → `{ "capabilityToken", "expiresIn" }`. Scope is exactly one of
  `workspace:read`, `workspace:write`, `workspace:create`.
  Scopes `workspace:read`/`workspace:write` require `workspaceId`. TTL max 3600s.
  Present it as `Authorization: Bearer <capabilityToken>`.
  Capabilities cannot refresh and cannot list workspaces.

Revocation (you may only revoke your own agent/sessions/capabilities):

- `POST /api/v1/auth/revoke` `{ "type": "session|capability|agent", "id": "..." }`
  The `id` for a session/capability is its `jti`: decode the middle segment
  of your own token (`echo "$TOKEN" | cut -d. -f2 | base64 -d`) and read `jti`.
  There is no list-sessions endpoint in this milestone. Record `jti` values
  when you mint tokens if you plan to revoke them.
- `POST /api/v1/auth/keys/rotate` → bumps the signing key; previous key stays
  valid 24h (dual-accept), so rotation never breaks live sessions instantly.

## 6. Quickstart (curl)

```bash
set -euo pipefail
BASE=http://localhost:3000/api/v1
C=$(printf "%s" "my stable agent phrase" | shasum -a 256 | cut -d' ' -f1)
ENROLL=$(curl -s -H 'content-type: application/json' $BASE/auth/enroll -d "{\"clientSha256\":\"$C\"}")
AGENT=$(echo "$ENROLL" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['agentId'])")
ETOK=$(echo "$ENROLL" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['enrollmentToken'])")
NONCE=$(curl -s -H 'content-type: application/json' $BASE/auth/challenge -d "{\"agentId\":\"$AGENT\",\"enrollmentToken\":\"$ETOK\"}" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['nonce'])")
PROOF=$(printf "%s" "$C:$NONCE" | shasum -a 256 | cut -d' ' -f1)
SESS=$(curl -s -H 'content-type: application/json' $BASE/auth/verify -d "{\"agentId\":\"$AGENT\",\"nonce\":\"$NONCE\",\"commitmentResponse\":\"$PROOF\"}" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['sessionToken'])")
WS=$(curl -s -H "Authorization: Bearer $SESS" -H 'content-type: application/json' $BASE/workspaces -d '{"name":"demo"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['workspaceId'])")
BODY="hello hindsite"
printf "%s" "$BODY" | curl -s -H "Authorization: Bearer $SESS" -H 'content-type: application/octet-stream' -H "x-content-sha256: $(printf "%s" "$BODY" | shasum -a 256 | cut -d' ' -f1)" -T - $BASE/workspaces/$WS/files/greet.txt
curl -s -H "Authorization: Bearer $SESS" $BASE/workspaces/$WS/files/greet.txt
```

## 7. Soul data plane (Postgres + object storage)

Workspaces above are the local adapter. The soul data plane persists the same
encrypted-file model in Postgres (metadata, wrapped DEKs, versions, audit) with
ciphertext blobs in object storage. Session token only; every call acts as the
calling agent on its own enclave.

- `POST /api/v1/soul/enclave` `{ "name": "soul" }` → `{ "agentId", "workspaceId", "manifest", ... }` (409 `enclave_exists` on re-create)
- `GET /api/v1/soul/enclave` → your enclave summary (404 `enclave_not_found`)
- `PUT /api/v1/soul/files/{path...}` → `{ "path", "version", "size", "updatedAt" }` (versions increment per write)
- `GET /api/v1/soul/files/{path...}` → decrypted bytes, `accept-ranges: bytes`, `?version=n` reads history
- `GET /api/v1/soul/files?prefix=memory/` → list `[{ path, version, size, updatedAt }]`
- `DELETE /api/v1/soul/files/{path...}` → `{ "deleted": true }`
- `GET /api/v1/soul/audit?limit=100` → `[{ ts, actor, action, detail }]`
- `POST /api/v1/soul/wallets` `{ "label": "primary" }` → `{ "walletId", "chain": "evm", "address", ... }` (metadata only; secret is sealed + backed up, never returned here)
- `GET /api/v1/soul/wallets` → your wallets, metadata only
- `POST /api/v1/soul/wallets/{id}/export` → `{ ..., "secret": "0x…" }` (recovery; audit-logged as `wallet.export`)
- `GET /api/health` (no auth) → `{ "ok", "data": { "checks": { "postgres", "storage" } } }`, 503 while any dependency is down or unconfigured

Soul routes answer 503 `dataplane_unconfigured` when the deployment has no
`DATABASE_URL` / `STORAGE_*` configured.

## 8. Error codes

| HTTP | `error.code`                                                                  | Meaning                                    | Recovery                                       |
| ---- | ----------------------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
| 400  | `invalid_commitment`                                                          | `clientSha256` not 64 hex                  | Fix input, re-enroll                           |
| 400  | `missing_*`, `invalid_*`                                                      | Bad/missing field, scope, ttl, path, range | Fix input, retry                               |
| 400  | `content_mismatch`                                                            | `x-content-sha256` != body bytes           | Recompute hash, retry PUT                      |
| 401  | `token_invalid`                                                               | Bad signature/type/unknown jti             | Do not retry; re-authenticate                  |
| 401  | `bad_proof`, `challenge_unknown`                                              | Wrong proof or unknown nonce               | Re-challenge with fresh nonce                  |
| 403  | `capability_denied`, `workspace_denied`, `revoke_denied`                      | Scope/ownership violation                  | Use a sufficient token                         |
| 403  | `agent_revoked`, `session_revoked`, `capability_revoked`                      | Revoked credential                         | Re-authenticate (agent revoke is final)        |
| 404  | `workspace_not_found`, `file_not_found`                                       | No such object                             | Check id/path                                  |
| 404  | `enclave_not_found`, `version_not_found`, `blob_missing`                      | No enclave / version / blob                | Create enclave; check version                  |
| 400  | `unsupported_chain`                                                             | Unknown wallet chain                       | Use `evm`                                      |
| 404  | `wallet_not_found`                                                              | No such wallet                             | Check wallet id                                |
| 409  | `enclave_exists`                                                              | Agent already has an enclave               | Use GET, do not re-create                      |
| 410  | `token_expired`, `enrollment_expired`, `challenge_expired`, `refresh_expired` | Time expired                               | Re-authenticate / re-enroll                    |
| 410  | `enrollment_used`, `challenge_used`, `refresh_used`                           | Single-use replay                          | Start that step over; never retry a used token |
| 413  | `payload_too_large`                                                           | Body over 25 MiB                           | Split into multiple files                      |
| 416  | `range_not_satisfiable`                                                       | Range start beyond file size               | Re-check size, fix Range                       |
| 500  | `internal`                                                                    | Server bug                                 | Report; safe to retry idempotent GETs          |
| 503  | `dataplane_unconfigured`                                                      | No DATABASE_URL / STORAGE_*                | Configure data plane, retry                    |

## 9. Limits and non-goals

- One agent sees only its own workspaces; there is no sharing yet.
- No binary websocket endpoint in this milestone (SSE + Range instead).
- No multipart direct-to-object-storage uploads yet (25 MiB request cap).
- Workspaces (§2–§4) are the local adapter; the soul data plane (§7) persists
  the same encrypted-file model in Postgres + object storage (see `docs/railway.md`).
