Solid Protocol identity for AI agents
WebID pods · WAC access control · IPFS content addressing · Bitcoin-anchored audit via OpenTimestamps
OpenID is a Solid Protocol server written in Go. It gives each AI agent a WebID, a pod, and a tamper-evident audit trail: every mutating action is hashed, pinned to IPFS, Merkle-batched, and stamped with OpenTimestamps (Bitcoin calendar attestation).
Not the same as OpenID Connect the SSO standard — though this server also exposes OIDC discovery and client-credentials for machine agents. Here OpenID means open identity for agents on Solid.
| Need | What OpenID provides |
|---|---|
| Agent identity | Solid WebID + Ed25519 key + optional did:key |
| Private data store | LDP pod with WAC ACLs |
| Interop | HTTP Linked Data (Turtle, SPARQL UPDATE, N3-Patch) |
| Proof of action | IPFS CID + Merkle path + OTS Bitcoin calendar proof |
| Machine auth | Bearer JWT, DPoP, client-credentials, agent signatures |
git clone https://github.com/Hewlbern/openID.git
cd openID
go run ./cmd/server -port 3000 -storage ./dataOpen the Solid server dashboard (this is the server console, not the marketing landing):
http://localhost:3000/
Handle-claim landing (separate product page):
http://localhost:3000/welcome
Health check:
curl -s http://localhost:3000/health
# {"status":"ok"}MCP (Grok Bot / Cursor / Gemini Spark): http://localhost:3000/mcp — also stdio via go run ./cmd/mcp with OPENID_BASE_URL.
curl -s http://localhost:3000/mcp
# {"open":true,"dashboard":"...","mcp":".../mcp", ...}Claim a handle:
curl -s http://localhost:3000/idp/handles/ada
curl -s -X POST http://localhost:3000/idp/register \
-H 'Content-Type: application/json' \
-d '{"handle":"ada","password":"testpass123","name":"Ada"}'
# Public page: http://localhost:3000/i/ada
# Passport UI: http://localhost:3000/appThe happy path is inside Gemini Spark. Spark is a custom MCP client of this OpenID server. You stay in the chat; Spark calls spark_save_conversation with the current thread. The passport Save dialog on /app is a paste fallback only. OpenID does not scrape Gemini.
Writes land at {pod}/{handle}/conversations/spark/{id}.json (JSON-LD transcript) plus {id}.ttl (Conversation / Message RDF with dcterms:created, dcterms:modified, schema:dateCreated, per-message timestamps, foaf/schema roles, owner WebID, source=gemini-spark). Containers conversations/ and conversations/spark/ are created first (paths end with /). Every write is an audited LDP PUT (IPFS / OpenTimestamps).
Do not paste the forever login Bearer into Spark. On /app (while signed in) use the Spark connect panel:
- Open
https://<origin>/app(hosted: https://identity-two-plum.vercel.app/app ). - Click Create / copy connect token. Copy MCP URL (
https://<origin>/mcp) and the token. - Gemini Spark → Settings → Custom Connected Apps / MCP → paste URL +
Authorization: Bearer <connect token>. - In a chat say:
Save this conversation to my Solid pod. - Revoke invalidates every current Spark connect token for your WebID.
The connect token is a distinct JWT (aud: spark-mcp, scope: spark, unique jti). Default lifetime is 30 days (SOLID_SPARK_TOKEN_TTL, e.g. 720h). It can only call Spark MCP tools (save / list / get / share / unshare) and read/write your {handle}/conversations/ container. It cannot mint more tokens, write another pod, or call general pod tools.
POST /api/spark-token # session required → { token, expires, jti, mcpUrl, webId }
GET /api/spark-token # list active grants (no secret)
DELETE /api/spark-token # revoke all, or ?jti=
These routes are implemented on the Vercel passport. Railway /idp/spark-token is optional.
Spark must call spark_save_conversation with title and messages: [{role, content|text, timestamp?}]. It returns resourceUrl, webId, optional shareUrl, and confirmation text to show you.
MCP tools: spark_save_conversation, spark_list_conversations, spark_get_conversation, spark_share_conversation, spark_unshare_conversation.
If Spark is not connected, open /app, click Save, and paste a **User:** / **Gemini:** transcript or a public g.co/gemini/share/… link. Google does not publish a Gemini bulk export API.
# after login (same payload Spark sends)
curl -s -X POST https://identity-two-plum.vercel.app/api/spark-conversations \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Spark","messages":[{"role":"user","text":"hi","timestamp":"2026-09-01T10:00:00Z"},{"role":"assistant","text":"hello"}]}'The Solid server is a long-lived Go process with a volume (LDP storage). The marketing / passport UI is on Vercel. Register/login stay same-origin via /idp (proxied to the pod). Save, list, share, Spark connect tokens, and MCP run as Vercel APIs and talk to the pod with LDP — they do not require a Railway redeploy. Browser auth is Bearer in localStorage plus an HttpOnly solid-session cookie.
| Role | URL |
|---|---|
| Passport / marketing | https://identity-two-plum.vercel.app |
| Solid pod (Railway) | https://pod-production-ebe1.up.railway.app |
Create an ID and log in (about a minute)
- Open https://identity-two-plum.vercel.app
- Claim a handle + password (or Sign in).
- You land on
/app. Your WebID is shown on the account pane (https://pod-production-ebe1.up.railway.app/{handle}/profile/card#me). - On
/app, Create / copy connect token, addhttps://identity-two-plum.vercel.app/mcpin Spark, then say Save this conversation to my Solid pod. - Or use Save on
/appas a paste fallback, then Share. - Sign out, then sign back in with the same handle + password.
Environment
| Variable | Where | Meaning |
|---|---|---|
SOLID_BASE_URL |
Railway / cmd/server |
Must be the public pod origin (https://pod-production-ebe1.up.railway.app). WebIDs are minted from this. |
SOLID_TOKEN_SECRET |
Railway | JWT HMAC secret. Required in production. |
SOLID_STORAGE_PATH |
Railway | Persistent volume (e.g. /data). |
OPENID_POD |
Vercel build | Pod origin the site proxies to. Defaults to the Railway URL above. |
OPENID_API |
Vercel build | Leave empty so the browser stays on the Vercel origin. |
OPENID_SPARK_SECRET |
Vercel | HMAC secret for 30-day Spark connect tokens. Falls back to SOLID_TOKEN_SECRET then a preview default. Set a real secret in production. |
# frontend (repo root or frontend/)
OPENID_POD=https://pod-production-ebe1.up.railway.app vercel --prodPassport features (save / list / share / Spark connect token / MCP) are implemented on Vercel (/api/spark-conversations, /api/spark-share, /share/c/…, /api/spark-token, /mcp). Railway is only the Solid volume. A Railway redeploy is optional and not required for those use cases.
Railway (pod origin)
- Create a new Railway project from this repo (
Dockerfile+railway.toml). - Attach a volume at
/data. - Set
SOLID_STORAGE_PATH=/data, a realSOLID_TOKEN_SECRET, andSOLID_BASE_URL=https://<your-railway-domain>after the domain exists. - Railway injects
PORT; the binary already reads it.
Vercel (frontend)
cd frontend
OPENID_API=https://<your-railway-domain> vercel --prodThe pod is the product. Anyone can run the same Docker image — locally, on Railway, or anywhere that can keep a volume. The Vercel site and any future desktop app are optional clients of that origin. They are not required to run a pod.
# published image, persistent volume, dashboard at http://localhost:3000
docker compose up -dSet SOLID_BASE_URL to the URL others will use for WebIDs. To also keep a replica of a handle on another origin (for example Railway):
SOLID_BASE_URL=http://localhost:3000 \
SOLID_SYNC_PEER=https://pod-production-ebe1.up.railway.app \
SOLID_SYNC_HANDLE=mike \
SOLID_SYNC_PASSWORD=… \
docker compose up -dOptional Kubo (audit CIDs): docker compose --profile ipfs up -d and IPFS_API=http://ipfs:5001.
Other operators can run the same split: one SolidGo instance (Railway/Docker) plus a Vercel project whose OPENID_API points at their server.
# 1. Register an agent → WebID, keypair, Bearer token
curl -s -X POST http://localhost:3000/agents \
-H 'Content-Type: application/json' \
-d '{"name":"Atlas"}' | jq
# 2. Write to the agent pod (audited)
TOKEN=<token from step 1>
POD=agent-<id>/
curl -s -X PUT "http://localhost:3000/${POD}inbox/task.json" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"task":"summarize"}'
# 3. Anchor audit batch (Merkle root → OpenTimestamps)
curl -s -X POST http://localhost:3000/audit/flush | jq
# 4. Verify an event
curl -s "http://localhost:3000/audit/events/<event-id>/verify" | jqInstead of a Bearer token, agents can sign each request:
| Header | Value |
|---|---|
X-Agent-WebID |
Agent WebID |
X-Agent-Public-Key |
base64url Ed25519 public key |
X-Agent-Timestamp |
Unix seconds |
X-Agent-Signature |
Ed25519 over METHOD|PATH|TIMESTAMP|WEBID |
sequenceDiagram
participant Agent as AI_Agent
participant OpenID as OpenID_Server
participant IPFS as IPFS_Kubo
participant OTS as OpenTimestamps
participant BTC as Bitcoin
Agent->>OpenID: Register agent pod plus Ed25519 pubkey
OpenID->>OpenID: Mint WebID profile and ACL
Agent->>OpenID: Signed action on resource
OpenID->>OpenID: Verify sig against WebID key
OpenID->>OpenID: Append audit event hash
OpenID->>IPFS: Pin event payload CID
OpenID->>OpenID: Batch event hashes into Merkle root
OpenID->>OTS: Stamp Merkle root
OTS->>BTC: Calendar anchors to Bitcoin
Agent->>OpenID: GET audit proof for action
OpenID->>Agent: CID plus OTS proof plus Merkle path
When OTS calendars are unreachable, a pending local proof is stored and can be upgraded later via verify.
| Endpoint | Purpose |
|---|---|
GET/POST /mcp |
MCP for Grok Bot and Gemini Spark (Vercel /api/mcp; session Bearer or Spark token) |
GET/POST /api/spark-conversations |
Save / list Spark conversations (Vercel → LDP; no Railway /conversations) |
POST /api/spark-conversations/{id}/share |
Mint /share/c/{token} (public snapshot) |
POST /api/spark-conversations/{id}/unshare |
Revoke share (public GET 404 afterwards) |
GET /share/c/{token} |
Public read-only conversation page (logged-out) |
GET/POST/DELETE /api/spark-token |
Mint / list / revoke 30-day Spark connect tokens |
POST /agents |
Register AI agent (pod + WebID + keys) |
GET /agents |
List agents |
POST /idp/register |
Human account + pod |
POST /idp/login |
Password login |
POST /idp/client-credentials |
Machine client id/secret |
POST /oauth/token |
Token endpoint |
GET /.well-known/openid-configuration |
OIDC discovery |
GET /.well-known/solid |
Solid storage description |
GET HEAD PUT POST PATCH DELETE OPTIONS on resource paths.
- Containers end with
/and return Turtleldp:contains - Create containers with
Link: <http://www.w3.org/ns/ldp#BasicContainer>; rel="type" - Patch with
Content-Type: application/sparql-updateortext/n3 - ACLs at
{resource}.aclor{container}/.acl
| Endpoint | Purpose |
|---|---|
GET /audit/events/ |
List audit events |
GET /audit/events/{id} |
Event detail (includes IPFS CID) |
GET /audit/events/{id}/verify |
Hash + Merkle + OTS check |
POST /audit/flush |
Force Merkle batch + OTS stamp |
GET /audit/batches/{id} |
Batch + Merkle paths + OTS proof |
| Endpoint | Purpose |
|---|---|
GET /notifications/websocket?topic= |
WebSocket activity stream |
GET /notifications/stream?topic= |
SSE activity stream |
| Flag / env | Default | Meaning |
|---|---|---|
-port / SOLID_PORT |
3000 |
Listen port |
-storage / SOLID_STORAGE_PATH |
./data |
File storage root |
-base-url / SOLID_BASE_URL |
http://localhost:{port} |
Public base URL for WebIDs |
-ipfs-api / IPFS_API |
(empty = offline CIDs) | Kubo HTTP API |
SOLID_TOKEN_SECRET |
dev default | JWT HMAC secret |
AUDIT_BATCH_INTERVAL |
30s |
Merkle/OTS batch period |
OTS_CALENDAR |
public OTS calendars | Comma-separated calendar URLs |
go test ./internal/... ./test/...
go build -o openid ./cmd/server
docker compose --profile test run --rm solid-testsgo build -o /tmp/openid ./cmd/server
/tmp/openid -port 3460 -storage /tmp/openid-data -base-url http://localhost:3460 &
BASE_URL=http://localhost:3460 ./test/functional/run.shCovers health, OIDC discovery, register/login, LDP CRUD, ETags/conditions, SPARQL + N3 patch, WAC, client-credentials, AI agent + Ed25519 signatures, audit Merkle/OTS verify, SSE notifications, CORS, and the grokbot MCP (HTTP + stdio).
BASE_URL=http://localhost:3460 ./test/functional/mcp.shGrok Bot / Cursor load .cursor/mcp.json and .mcp.json in this repo (url: http://127.0.0.1:4000/mcp when the local pod is on port 4000). Gemini Spark should use https://<origin>/mcp with a Bearer token from /idp/login.
assets/openid-icon.png brand mark
cmd/server/ entrypoint
internal/solid/ LDP HTTP handler
internal/resourcestore/ containers, ETags, metadata
internal/wac/ Web Access Control
internal/authn/ Bearer / DPoP / agent signatures
internal/identityapi/ accounts, OIDC, client credentials
internal/agent/ AI agent registry
internal/audit/ hash chain + Merkle batches
internal/ipfs/ Kubo client (+ offline fallback)
internal/ots/ OpenTimestamps client
internal/notify/ WebSocket + SSE
internal/openidmcp/ MCP stdio + HTTP for Grok Bot and Gemini Spark
internal/conversations/ Spark save / list / share (pod + WAC)
internal/rdf/ Turtle / SPARQL UPDATE / N3-Patch
cmd/mcp/ stdio MCP entrypoint
Apache-2.0 — see LICENSE.
Author: Michael Holborn