From 4f8fe64f401568deda60196d40b6675ccb89b502 Mon Sep 17 00:00:00 2001 From: Mikael Rousson Date: Fri, 5 Jun 2026 19:17:18 +0800 Subject: [PATCH] Add Agent Relay skills for inbox delivery, E2EE uploads, and Railway ops. Community-contributed skills from mmmikael/arelay-skills for delivering agent artifacts to arelay.app, encrypting sensitive deliveries, and self-hosting on Railway. Co-authored-by: Cursor --- skills.sh.json | 9 ++ skills/agent-relay-api/SKILL.md | 90 ++++++++++++ .../references/api-reference.md | 59 ++++++++ skills/agent-relay-api/references/examples.md | 67 +++++++++ skills/agent-relay-e2ee/SKILL.md | 100 +++++++++++++ .../agent-relay-e2ee/scripts/e2ee-upload.mjs | 133 ++++++++++++++++++ skills/agent-relay-railway/SKILL.md | 92 ++++++++++++ 7 files changed, 550 insertions(+) create mode 100644 skills/agent-relay-api/SKILL.md create mode 100644 skills/agent-relay-api/references/api-reference.md create mode 100644 skills/agent-relay-api/references/examples.md create mode 100644 skills/agent-relay-e2ee/SKILL.md create mode 100644 skills/agent-relay-e2ee/scripts/e2ee-upload.mjs create mode 100644 skills/agent-relay-railway/SKILL.md diff --git a/skills.sh.json b/skills.sh.json index bd64253b..eca11dc3 100644 --- a/skills.sh.json +++ b/skills.sh.json @@ -27,6 +27,15 @@ "skills": [ "web-design-guidelines" ] + }, + { + "title": "Agent Relay", + "description": "Skills for delivering artifacts to humans via Agent Relay (arelay.app) — inbox API, end-to-end encrypted uploads, and Railway self-hosting.", + "skills": [ + "agent-relay-api", + "agent-relay-e2ee", + "agent-relay-railway" + ] } ] } diff --git a/skills/agent-relay-api/SKILL.md b/skills/agent-relay-api/SKILL.md new file mode 100644 index 00000000..e143e36e --- /dev/null +++ b/skills/agent-relay-api/SKILL.md @@ -0,0 +1,90 @@ +--- +name: agent-relay-api +description: Deliver files and reports to a human via the Agent Relay HTTP API (arelay.app). Use when sending deliverables outside chat, creating inbox sessions, uploading Markdown/HTML/images/PDFs, or when the user mentions Agent Relay, arelay, or agent inbox delivery. +license: MIT +metadata: + author: mmmikael + version: "1.0.0" +--- + +# Agent Relay API + +Deliver artifacts to the human's private inbox instead of email or chat attachments. Each delivery is a **session** (one thread); files are **artifacts**. + +## When to use + +- HTML pages, Markdown reports, images, PDFs, or multiple related files from one task +- Human should preview/download in the web portal + +Do **not** use for short chat replies or secrets the human did not ask you to store. + +## Configuration + +Set in the agent environment (never commit): + +| Variable | Value | +| --- | --- | +| `AGENT_RELAY_URL` | `https://arelay.app` or self-hosted base URL (no trailing slash). For Railway, use exact `RAILWAY_PUBLIC_DOMAIN` — wrong host returns 404. | +| `AGENT_API_TOKEN` | Bearer token from the human's Agent Relay account → Agent tokens | + +Every request: + +```http +Authorization: Bearer +``` + +## Workflow + +1. `POST /api/agent/sessions` with `title` and optional `summary` +2. `POST /api/agent/sessions//artifacts` for each file +3. Optionally `PATCH /api/agent/sessions/` to update summary +4. Tell the human: *"Sent to Agent Relay — session: \"* (no UUID needed) + +One session per logical delivery. Re-use the same `session_id` for all files in that delivery. + +## Upload methods + +| Content | Method | Content-Type | +| --- | --- | --- | +| Markdown, HTML, text, JSON | JSON body with `content` | `application/json` | +| Images, PDFs, binaries | `multipart/form-data` field `file` | `multipart/form-data` | + +JSON artifact fields: `filename`, `content_type`, `content` (required). + +Common `content_type`: `text/markdown`, `text/html`, `text/plain`, `application/json`, `image/png`, `application/pdf`. + +## Storage limits + +- **25 MB** per artifact → `413` +- **500 MB** per account → `507` + +## Errors + +| Status | Meaning | +| --- | --- | +| `401` | Invalid or revoked token | +| `404` | Unknown session or E2EE not configured | +| `413` | File too large | +| `507` | Account quota full | +| `503` | S3 not configured on server | + +Read `{ "error": "..." }` and report to the human. + +## Checklist before finishing + +- [ ] `title` is plain language; `summary` says what to open first +- [ ] Sensible `filename` extensions (`.md`, `.html`, `.png`, …) +- [ ] Text/HTML via JSON; binaries via multipart +- [ ] Human notified (portal refreshes in ~5 seconds) + +## Examples + +See [references/examples.md](references/examples.md) for curl and Python. + +## Encrypted deliveries + +If content is sensitive, load the **agent-relay-e2ee** skill and check `GET /api/agent/e2ee/config` first. + +## Full reference + +Endpoint details: [references/api-reference.md](references/api-reference.md) diff --git a/skills/agent-relay-api/references/api-reference.md b/skills/agent-relay-api/references/api-reference.md new file mode 100644 index 00000000..ef569b42 --- /dev/null +++ b/skills/agent-relay-api/references/api-reference.md @@ -0,0 +1,59 @@ +# Agent Relay API reference + +Base: `{AGENT_RELAY_URL}` + +## Create session + +```http +POST /api/agent/sessions +Content-Type: application/json + +{ + "title": "Short human-readable title", + "summary": "Optional one-line description" +} +``` + +**201:** `{ "session": { "id", "title", "summary", "created_at", "updated_at" } }` + +## Upload artifact (JSON) + +```http +POST /api/agent/sessions//artifacts +Content-Type: application/json + +{ + "filename": "report.md", + "content_type": "text/markdown", + "content": "# Title\n\nBody..." +} +``` + +**201:** `{ "artifact": { "id", "session_id", "filename", "content_type", "size_bytes", "created_at" } }` + +## Upload artifact (multipart) + +```http +POST /api/agent/sessions//artifacts +Content-Type: multipart/form-data + +file= +filename=optional-override.png +``` + +## Update session + +```http +PATCH /api/agent/sessions/ +Content-Type: application/json + +{ "title": "...", "summary": "..." } +``` + +## List sessions + +```http +GET /api/agent/sessions +``` + +Returns `{ "sessions": [ ... ] }` by `updated_at` descending. diff --git a/skills/agent-relay-api/references/examples.md b/skills/agent-relay-api/references/examples.md new file mode 100644 index 00000000..8a212918 --- /dev/null +++ b/skills/agent-relay-api/references/examples.md @@ -0,0 +1,67 @@ +# Agent Relay API examples + +Set `AGENT_RELAY_URL` and `AGENT_API_TOKEN` in the agent environment before running these examples. + +## curl — Markdown + image + +```bash +BASE="$AGENT_RELAY_URL" +TOKEN="$AGENT_API_TOKEN" + +SESSION=$(curl -s -X POST "$BASE/api/agent/sessions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"title":"Weekly report","summary":"Metrics and chart"}' \ + | jq -r '.session.id') + +curl -s -X POST "$BASE/api/agent/sessions/$SESSION/artifacts" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"filename":"report.md","content_type":"text/markdown","content":"# Weekly report\n\nAll good."}' + +curl -s -X POST "$BASE/api/agent/sessions/$SESSION/artifacts" \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@./chart.png" +``` + +## Python + +Use the same two environment variables. Example with explicit values (substitute from your host env): + +```python +import requests + +relay_url = "https://arelay.app" # AGENT_RELAY_URL +api_token = "ar_your_token_here" # AGENT_API_TOKEN + +BASE = relay_url.rstrip("/") +HEADERS = {"Authorization": f"Bearer {api_token}"} + +r = requests.post( + f"{BASE}/api/agent/sessions", + headers={**HEADERS, "Content-Type": "application/json"}, + json={"title": "API design draft", "summary": "OpenAPI + notes"}, + timeout=60, +) +r.raise_for_status() +session_id = r.json()["session"]["id"] + +requests.post( + f"{BASE}/api/agent/sessions/{session_id}/artifacts", + headers={**HEADERS, "Content-Type": "application/json"}, + json={ + "filename": "design.md", + "content_type": "text/markdown", + "content": "# API design\n\n...", + }, + timeout=60, +).raise_for_status() + +with open("diagram.png", "rb") as f: + requests.post( + f"{BASE}/api/agent/sessions/{session_id}/artifacts", + headers=HEADERS, + files={"file": ("diagram.png", f, "image/png")}, + timeout=120, + ).raise_for_status() +``` diff --git a/skills/agent-relay-e2ee/SKILL.md b/skills/agent-relay-e2ee/SKILL.md new file mode 100644 index 00000000..22a7b5ea --- /dev/null +++ b/skills/agent-relay-e2ee/SKILL.md @@ -0,0 +1,100 @@ +--- +name: agent-relay-e2ee +description: Upload end-to-end encrypted sessions and artifacts to Agent Relay using P-256 ECDH and AES-256-GCM. Use when deliverables are sensitive, when GET /api/agent/e2ee/config returns configured, or when the human requires encrypted agent delivery to arelay.app. +license: MIT +metadata: + author: mmmikael + version: "1.0.0" +--- + +# Agent Relay E2EE uploads + +The server stores only ciphertext. Decryption happens in the human's browser after they unlock their encryption key. + +## Prerequisites + +1. Human has set up encryption in the Agent Relay portal (passkey + recovery key). +2. Agent has `AGENT_RELAY_URL` and `AGENT_API_TOKEN` (same as plaintext API). + +## Check encryption status + +```http +GET /api/agent/e2ee/config +Authorization: Bearer +``` + +- **200** `{ "configured": true, "publicKeyJwk": { ... } }` → encrypt locally before upload +- **404** → ask human to enable encryption before sending sensitive content + +## Envelope format + +Each encrypted string or file uses: + +```json +{ + "v": 1, + "alg": "P-256-ECDH-A256GCM", + "epk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }, + "iv": "base64url-no-padding", + "ciphertext": "base64url-no-padding" +} +``` + +Use P-256 ECDH with the relay `publicKeyJwk`, derive AES-256-GCM key, fresh ephemeral key + IV per field/file. + +**Important:** The browser uses Web Crypto `deriveKey(ECDH → AES-GCM)`. Hand-rolled Python `cryptography` ECDH/HKDF often produces incompatible ciphertext. Prefer the bundled reference script: + +```bash +AGENT_RELAY_URL=https://arelay.app AGENT_API_TOKEN=ar_... \ + node scripts/e2ee-upload.mjs "Delivery title" "report.md" "# Report body" +``` + +(`scripts/e2ee-upload.mjs` ships with this skill.) + +## Create encrypted session + +```http +POST /api/agent/sessions +Content-Type: application/json + +{ + "encrypted": true, + "encrypted_title": { "...": "title envelope" }, + "encrypted_summary": { "...": "optional summary envelope" } +} +``` + +## Upload encrypted artifact + +```http +POST /api/agent/sessions//artifacts +Content-Type: application/json + +{ + "encrypted": true, + "encrypted_filename": { "...": "filename envelope" }, + "encrypted_content_type": { "...": "content-type envelope" }, + "encrypted_payload": { + "v": 1, + "alg": "P-256-ECDH-A256GCM", + "epk": { "...": "ephemeral public JWK" }, + "iv": "base64url-no-padding" + }, + "ciphertext_base64": "base64url-no-padding", + "size_bytes": 12345 +} +``` + +`encrypted_payload` is the file envelope **without** `ciphertext`; file bytes go in `ciphertext_base64`. + +## Storage limits + +Same as plaintext: **25 MB** per artifact, **500 MB** per account. + +## Plaintext fallback + +If E2EE is not configured and content is not sensitive, use **agent-relay-api** for standard uploads. + +## Reference + +API endpoints: [agent-relay-api/references/api-reference.md](../agent-relay-api/references/api-reference.md) diff --git a/skills/agent-relay-e2ee/scripts/e2ee-upload.mjs b/skills/agent-relay-e2ee/scripts/e2ee-upload.mjs new file mode 100644 index 00000000..714a8de2 --- /dev/null +++ b/skills/agent-relay-e2ee/scripts/e2ee-upload.mjs @@ -0,0 +1,133 @@ +/** + * Reference encrypted upload for Agent Relay agents. + * Matches Web Crypto in arelay src/lib/e2ee.ts — do not hand-roll Python ECDH/HKDF. + * + * Usage: + * AGENT_RELAY_URL=https://arelay.app AGENT_API_TOKEN=ar_... node e2ee-upload.mjs "Title" "file.md" + */ +import { webcrypto } from 'node:crypto'; + +const relayUrl = (process.env.AGENT_RELAY_URL ?? 'https://arelay.app').replace(/\/$/, ''); +const apiToken = process.env.AGENT_API_TOKEN; +if (!apiToken) { + console.error('AGENT_API_TOKEN is required'); + process.exit(1); +} + +const TEXT_ENCODER = new TextEncoder(); + +function bytesToBase64Url(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); +} + +function toArrayBuffer(bytes) { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +async function importPublicKey(publicKeyJwk) { + return webcrypto.subtle.importKey( + 'jwk', + { kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y }, + { name: 'ECDH', namedCurve: 'P-256' }, + true, + [] + ); +} + +async function deriveContentKey(privateKey, publicKey, usages) { + return webcrypto.subtle.deriveKey( + { name: 'ECDH', public: publicKey }, + privateKey, + { name: 'AES-GCM', length: 256 }, + false, + usages + ); +} + +async function encryptBytes(plaintext, recipientPublicKeyJwk) { + const recipientPublicKey = await importPublicKey(recipientPublicKeyJwk); + const ephemeralKeyPair = await webcrypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveKey'] + ); + const contentKey = await deriveContentKey(ephemeralKeyPair.privateKey, recipientPublicKey, [ + 'encrypt' + ]); + const iv = webcrypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await webcrypto.subtle.encrypt( + { name: 'AES-GCM', iv: toArrayBuffer(iv) }, + contentKey, + toArrayBuffer(plaintext) + ); + const epk = await webcrypto.subtle.exportKey('jwk', ephemeralKeyPair.publicKey); + return { + v: 1, + alg: 'P-256-ECDH-A256GCM', + epk: { kty: epk.kty, crv: epk.crv, x: epk.x, y: epk.y }, + iv: bytesToBase64Url(iv), + ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)) + }; +} + +async function encryptString(plaintext, recipientPublicKeyJwk) { + return encryptBytes(TEXT_ENCODER.encode(plaintext), recipientPublicKeyJwk); +} + +function envelopeToPayload(envelope) { + const { ciphertext, ...payload } = envelope; + const base64 = ciphertext.replaceAll('-', '+').replaceAll('_', '/').padEnd(Math.ceil(ciphertext.length / 4) * 4, '='); + return { + payload, + ciphertextBytes: Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) + }; +} + +async function agentFetch(path, init = {}) { + const res = await fetch(`${relayUrl}${path}`, { + ...init, + headers: { Authorization: `Bearer ${apiToken}`, ...(init.headers ?? {}) } + }); + const body = await res.json().catch(() => null); + if (!res.ok) throw new Error(`${init.method ?? 'GET'} ${path} failed (${res.status}): ${JSON.stringify(body)}`); + return body; +} + +const title = process.argv[2] ?? 'Encrypted delivery'; +const filename = process.argv[3] ?? 'delivery.md'; +const content = process.argv[4] ?? '# Encrypted delivery\n\nSent via agent-relay-e2ee reference script.\n'; +const contentType = filename.endsWith('.md') ? 'text/markdown' : 'text/plain'; + +const config = await agentFetch('/api/agent/e2ee/config'); +if (!config?.configured) throw new Error('E2EE is not configured for this account'); + +const publicKeyJwk = config.publicKeyJwk; +const { session } = await agentFetch('/api/agent/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + encrypted: true, + encrypted_title: await encryptString(title, publicKeyJwk), + encrypted_summary: await encryptString('Encrypted artifact upload', publicKeyJwk) + }) +}); + +const fileEnvelope = await encryptBytes(TEXT_ENCODER.encode(content), publicKeyJwk); +const { payload, ciphertextBytes } = envelopeToPayload(fileEnvelope); + +const { artifact } = await agentFetch(`/api/agent/sessions/${session.id}/artifacts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + encrypted: true, + encrypted_filename: await encryptString(filename, publicKeyJwk), + encrypted_content_type: await encryptString(contentType, publicKeyJwk), + encrypted_payload: payload, + ciphertext_base64: bytesToBase64Url(ciphertextBytes), + size_bytes: ciphertextBytes.byteLength + }) +}); + +console.log(JSON.stringify({ sessionId: session.id, artifactId: artifact.id }, null, 2)); diff --git a/skills/agent-relay-railway/SKILL.md b/skills/agent-relay-railway/SKILL.md new file mode 100644 index 00000000..600754aa --- /dev/null +++ b/skills/agent-relay-railway/SKILL.md @@ -0,0 +1,92 @@ +--- +name: agent-relay-railway +description: Deploy and operate a self-hosted Agent Relay instance on Railway with PostgreSQL and S3. Use when self-hosting arelay, deploying mmmikael/arelay to Railway, configuring WEBAUTHN_ORIGIN, DATABASE_URL, S3, or troubleshooting Agent Relay production on Railway. +license: MIT +metadata: + author: mmmikael + version: "1.0.0" +--- + +# Agent Relay on Railway + +Deploy [Agent Relay](https://github.com/mmmikael/arelay) — SvelteKit inbox + agent HTTP API. + +## Prerequisites + +- Railway CLI authenticated (`railway login`, `railway whoami`) +- GitHub repo linked or `railway up` from clone +- PostgreSQL plugin on the project +- S3-compatible storage (AWS S3 or compatible) + +## Railway setup + +1. Create service from `github.com/mmmikael/arelay` +2. Add PostgreSQL → link `DATABASE_URL` +3. Set required variables (see below) +4. Build: `npm run build` — Start: `npm start` +5. Attach custom domain or use `RAILWAY_PUBLIC_DOMAIN` for `AGENT_RELAY_URL` + +## Required environment variables + +| Variable | Notes | +| --- | --- | +| `SESSION_SECRET` | `openssl rand -hex 32` | +| `DATABASE_URL` | From Railway PostgreSQL | +| `NODE_ENV` | `production` | +| `WEBAUTHN_RP_ID` | Apex domain, e.g. `arelay.app` | +| `WEBAUTHN_ORIGIN` | `https://arelay.app` (must match browser origin) | +| `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Artifact storage | +| `S3_ENDPOINT` | e.g. `https://s3.ap-southeast-1.amazonaws.com` for that region | +| `S3_PREFIX` | Default `agent-relay` | +| `EMAIL_FROM` + Cloudflare or SMTP | Account verification in production | + +Optional: `SESSION_VERSION` to invalidate sessions after secret rotation. + +## S3 region pitfall + +Bucket region must match `S3_REGION` and `S3_ENDPOINT`. Wrong region causes `PermanentRedirect` on artifact upload. + +## Agent URL for deliverables + +Set agents to: + +``` +AGENT_RELAY_URL=https:// +``` + +Use the **exact** Railway public domain until custom domain is live. Do not guess Railway hostnames. + +## Verify deployment + +```bash +railway status --json +railway logs --lines 100 +curl -s -o /dev/null -w "%{http_code}" "$AGENT_RELAY_URL/" +``` + +Human: create passkey at `/`, generate agent token in account menu. + +## Database + +Schema is applied on boot (`ensureSchema`). For local setup pattern: + +```bash +npm run db:setup +``` + +## IAM + +Minimal S3 policy scoped to prefix: `scripts/iam-agent-relay-s3-policy.json` in the arelay repo. + +## Operations + +```bash +railway variable list --json +railway logs --lines 200 +railway up --detach -m "deploy" +``` + +## Reference + +- App README: [github.com/mmmikael/arelay](https://github.com/mmmikael/arelay) +- Hosted alternative: [arelay.app](https://arelay.app)