Quick start
Install the Windows agent from Downloads. It pairs itself with your workspace in the browser and puts hashrelay on your PATH. Then, in a new terminal:
hashrelay subscribe #builds
hashrelay publish #builds "Build complete" "212 tests passed in 48s"The notification appears in Windows Notification Center on every device in the workspace subscribed to #builds. From CI or a server, create an API key in the dashboard and publish over HTTP:
curl -X POST https://hashrelay.dev.avlon.ca/api/v1/notifications \
-H "Authorization: Bearer hr_live_…" \
-H "Content-Type: application/json" \
-d '{"tags":["builds"],"title":"Build complete","body":"212 tests passed in 48s"}'Concepts
| Workspace | A tenant. Hashtags, keys, devices and notifications are isolated per workspace; nothing crosses the boundary. |
|---|---|
| Hashtag | A subscription topic inside a workspace. Normalised to lowercase [a-z0-9][a-z0-9_-]{0,63}; #Builds and builds are the same tag. |
| Endpoint | A device (or headless listener) with a stable id. It holds a set of subscriptions and receives at most one notification per publication, however many of its tags match. |
| API key | A tenant-bound publisher credential for trusted integrations. Shown once, stored hashed, revocable, with explicit publish/subscribe permissions. |
| Device token | A credential issued to one endpoint through pairing. Installed clients never carry your publisher API key. |
CLI reference
The CLI stores its configuration per user in %APPDATA%\HashRelay\config.json. Exit codes: 0 success, 2 invalid input, 3 authentication, 4 server unavailable, 5 request rejected, 10 unexpected. Add --json to any command for machine-readable output.
| hashrelay login | Pair this device with a workspace in the browser. Use --key hr_live_… for headless machines. |
| hashrelay configure --server URL | Point the CLI and agent at another HashRelay server. |
| hashrelay subscribe #tag… | Subscribe this device to one or more hashtags. |
| hashrelay unsubscribe #tag… | Remove subscriptions. |
| hashrelay subscriptions | List this device's subscriptions. |
| hashrelay publish #tag… "Title" ["Body"] | Publish. Also accepts --tag, --title, --body, --url, --priority, --id, and for rich content --markdown/--markdown-file, --diagram/--diagram-file (Mermaid) and --image (file or URL). Use - as a file to read stdin. |
| hashrelay listen [#tag…] | Stream notifications for this device to the console. |
| hashrelay test | Publish a test notification to this device and verify delivery. |
| hashrelay status | Show server, workspace, device and agent status. |
| hashrelay agent start|stop|status | Control the Windows tray agent. |
| hashrelay logout | Forget the stored credential. |
Authentication
Send your credential as a bearer token. Tenant identity is resolved from the credential; the API never trusts a client-supplied tenant id.
Authorization: Bearer hr_live_… # API key (dashboard → API keys)
Authorization: Bearer hr_dev_… # device token (issued by pairing)/api/v1/meReturns the tenant and credential the token resolves to.
Publish API
/api/v1/notifications publish{
"tags": ["builds", "ci"], // 1–10 hashtags, '#' optional
"title": "Build complete", // 1–120 chars
"body": "212 tests passed", // optional, ≤ 1000 chars
"url": "https://ci.example/run/42", // optional; opened when the toast is clicked
"priority": "normal", // low | normal | high
"notificationId": "uuid", // optional; makes retries idempotent
"markdown": "## Details\n…", // optional, ≤ 20 000 chars; rendered on the notification page
"diagram": "graph LR\nA-->B", // optional Mermaid source, ≤ 10 000 chars (else the first ```mermaid fence in markdown)
"imageUrl": "https://…/i/….png" // optional absolute image URL (see POST /api/v1/images)
}Rich content. A Windows toast shows plain text and one image, so markdown and diagram are rendered on a web page at /n/{notificationId} (members of the workspace only), which the toast opens when clicked. The toast body falls back to the first paragraph of the markdown. A diagram is also rendered to PNG on the server and shown inside the toast; the page renders it live with Mermaid, as it does any ```mermaid fence in the markdown. Responses include detailUrl and imageUrl.
/api/v1/images publishRaw body upload with Content-Type: image/png | image/jpeg | image/gif | image/webp, at most 3 MB. Returns 201 {"id","url","bytes","contentType"}; the URL is public but unguessable and is what you pass as imageUrl. From the CLI, --image path.png does this for you.
Returns 202 Accepted:
{
"notificationId": "7a2c…", "acceptedAtUtc": "2026-10-31T12:00:00Z",
"tags": ["builds","ci"], "title": "Build complete",
"matchedEndpoints": 3, "deliveredLive": 2, "queued": 1, "duplicate": false
}Re-sending the same notificationId with an identical payload returns 200 with duplicate: true; a different payload is rejected with 409. The union of subscribers across all tags is matched and each endpoint receives exactly one notification. Endpoints that are offline keep a queued delivery that is replayed when they reconnect within 24 hours.
/api/v1/notifications?limit=20Recent publications in the workspace with delivery counts.
Endpoints & subscriptions
/api/v1/endpoints/api/v1/endpoints subscribe{ "endpointId": "my-ci-runner", "platform": "linux", "name": "CI runner 1" } → 201 (or 200 if it existed)/api/v1/endpoints/{endpointId}/subscriptions/api/v1/endpoints/{endpointId}/subscriptions subscribe/api/v1/endpoints/{endpointId}/subscriptions subscribe/api/v1/endpoints/{endpointId}/subscriptions?tags=a&tags=b subscribePUT replaces, POST adds, DELETE removes; each takes or returns {"endpointId":"…","tags":["…"]}. Single-tag forms PUT/DELETE …/subscriptions/{tag} return 204 idempotently.
Device pairing
Installed clients obtain a device token with an OAuth-style device flow, so they never hold a publisher key:
POST /api/v1/device/code { "endpointId", "deviceName", "platform", "client": "agent" | "cli" }
→ { "deviceCode", "userCode": "KQ7M-3PXD", "verificationUrlComplete": "https://hashrelay.dev.avlon.ca/pair?code=KQ7M-3PXD", "expiresInSeconds": 900, "intervalSeconds": 3 }
POST /api/v1/device/token { "deviceCode" }
→ { "status": "pending" | "approved" | "denied" | "expired", "token": "hr_dev_…", "tenant": {…}, "credential": {…} }The person approves the code at /pair, choosing the workspace and whether the device may also publish. Approving again for the same endpoint replaces its previous token.
Realtime hub
Agents connect to the SignalR hub at /hubs/notifications with their token (Authorization header or ?access_token=). The server binds the endpoint identity in Hello, replays the backlog (queued deliveries, plus any handed to an earlier connection but never acknowledged) with replayed: true, and pushes:
// client → server
Hello({ endpointId, name, platform, agentVersion }) → { tenant, endpointId, subscriptions[], pendingReplay }
Subscribe(tags[]) / Unsubscribe(tags[]) → tags[]
Ack(deliveryId)
// server → client
NotificationReceived({ notificationId, tenantId, tags, title, body, publishedAtUtc, deliveryId, url, priority, tenantName, replayed,
imageUrl, detailUrl, rich, publisher: { id, name, type, channel } })
SubscriptionsChanged(tags[])
CredentialRevoked(reason)Groups are per-endpoint routing addresses only. Subscriptions are persisted and are the sole authority for matching. A subscription takes effect for notifications published after it is created; earlier notifications are not replayed to it (read them with GET /api/v1/notifications or the endpoint's /deliveries).
Catch-up. Clients acknowledge every delivery they receive, replayed or not, and acknowledgement means "received", not "a toast was shown". The Windows agent shows live deliveries at once and folds a replayed backlog into one summary toast ("47 notifications while you were away"), showing at most three high-priority items on their own; the summary opens the Activity page.
Publisher. publisher is set by the server from the credential: a service account (its name and the type an admin declared), an API key (its label and declared type) or a paired device (its name, type human). type is one of agent, application, service, human, unknown and is never inferred from MCP, REST or the message text; channel is the entry point (mcp, rest, sdk, cli, agent, mobile). Notifications stored before attribution existed carry type unknown. The same object appears on GET /api/v1/notifications and the deliveries inbox.
Errors & limits
{ "code": "validation_failed", "message": "The publication is not valid.", "traceId": "00-…", "details": { "title": ["Title must be 1–120 characters."] } }| 400 validation_failed | Malformed input; see details. |
| 401 unauthenticated | Missing, unknown, revoked or expired credential. |
| 403 forbidden | Credential lacks the permission, or a device token acted outside its endpoint. |
| 404 not_found | Unknown endpoint in this workspace. |
| 409 conflict | notificationId reused with a different payload. |
| 429 throttled | Over 120 publications per minute per credential. Honour Retry-After. |
MCP for AI agents
HashRelay is an MCP server, so Claude Code, Cursor, VS Code and your own agents can publish notifications as a tool call. The endpoint speaks Streamable HTTP and is authenticated with a service account key.
https://mcp.hashrelay.dev.avlon.ca/mcp Bearer hr_svc_…Create the account under Dashboard → Service accounts. Unlike an API key, a service account carries a policy that every request is checked against:
| permissions | notifications:publish, notifications:read, endpoints:read, subscriptions:write, images:write. New accounts get publish only. |
| allowed hashtags | Glob patterns such as agent-* or ci/*. A publish to any other hashtag is refused with 403 and logged. |
| link policy | url and imageUrl must be https, optionally restricted to listed hosts. |
| quota | Publishes per hour across all of the account's keys; over it, 429 throttled. |
| audit | Every publish, refusal, key issue and revocation is listed on the account's page. Disabling the account suspends all keys at once. |
The same key also works for the REST API above, with the same policy applied.
claude mcp add --transport http hashrelay https://mcp.hashrelay.dev.avlon.ca/mcp \
--header "Authorization: Bearer hr_svc_…"{
"mcpServers": {
"hashrelay": {
"url": "https://mcp.hashrelay.dev.avlon.ca/mcp",
"headers": { "Authorization": "Bearer hr_svc_…" }
}
}
}Tools
| publish_notification | tags, title, body, url, priority, markdown, diagram, imageUrl, notificationId. Returns matched, live and queued device counts plus the detail page URL. |
| list_tags | Hashtags that currently have subscribers, with counts and whether this credential may publish to each. |
| list_notifications | Recent notifications, optionally for one hashtag. Needs notifications:read. |
| get_notification | One notification with its markdown, diagram and delivery counts. Needs notifications:read. |
| list_endpoints | Devices, online state and subscriptions. Needs endpoints:read. |
| set_subscriptions | Add, remove or replace a device's hashtags. Needs subscriptions:write. |
The resource hashrelay://workspace tells the agent its workspace, permissions, allowed hashtags and remaining quota. Read-tool results are marked as untrusted content written by other publishers, and a publish-only account cannot read them at all, which keeps a misled agent from being fed instructions through notifications.
Browser-based clients need their origin listed in HashRelay:McpAllowedOrigins; requests with any other Origin header are refused. Hosted assistants that use OAuth (claude.ai and Claude Desktop connectors, ChatGPT) discover the authorization server through /.well-known/oauth-protected-resource: paste the MCP URL, sign in, and approve the consent screen. Each approval creates a service account named after the app, so the same policy, quota and activity log apply to it, and Disconnect on that service account revokes every token at once. Tokens last one hour and refresh automatically; clients register themselves (RFC 7591) and must use PKCE.
.NET client
The HashRelay.Client library (in the repository) wraps the API and hub used by the CLI and agent:
using HashRelay.Client;
using HashRelay.Contracts;
using var api = new HashRelayApi(new Uri("https://hashrelay.dev.avlon.ca/"), Environment.GetEnvironmentVariable("HASHRELAY_KEY"));
var result = await api.PublishAsync(new PublishRequest(["builds"], "Build complete", "212 tests passed"));
Console.WriteLine($"matched {result.MatchedEndpoints}, live {result.DeliveredLive}");