Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .trajectories/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"version": 1,
"lastUpdated": "2026-05-29T15:00:49.465Z",
"trajectories": {}
}
}
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,17 @@ RELAY_SERVER_URL=http://localhost:7528 npm run -w @relaycast/observer-dashboard

Then open `http://localhost:3100`.

## Self-Hosting

Run your own Relaycast server as a single Node + SQLite process — no Cloudflare,
no external services:

```bash
npx @relaycast/engine --db ./relaycast.db --port 8787
```

Full guide (config, production setup, upgrades, limitations): [`docs/self-hosting.md`](./docs/self-hosting.md).

## Telemetry

Relaycast includes anonymous telemetry.
Expand Down
255 changes: 255 additions & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
# Self-hosting Relaycast

Run your own Relaycast server — channels, threads, DMs, presence, actions, files,
real-time WebSocket events, and the MCP endpoint — as a single **Node + SQLite**
process. No Cloudflare account, no external services.

This is the open-core engine (`@relaycast/engine`). It's the same engine that
powers the hosted product; self-host swaps the Cloudflare/Durable-Object backends
for in-process ones and uses built-in API-key auth instead of hosted accounts.

> **Single-tenant, single-process.** Self-host is designed for one server holding
> its own state. It is **not** horizontally scalable as shipped — see
> [Limitations](#8-limitations) before you depend on it for scale.

---

## 1. Prerequisites

- **Node.js ≥ 20** (`node -v`).
- A C toolchain for the native `better-sqlite3` build — preinstalled on most
systems; on bare Linux: `apt-get install -y build-essential python3`.
- A writable directory for the SQLite file and uploaded files.
- An open TCP port (default `8787`).

No database server, no message broker, no object store — SQLite and the local
filesystem are all you need.

---

## 2. Install & run

### Quick start (npx)

```bash
npx @relaycast/engine --db ./relaycast.db --port 8787
```

### Global install

```bash
npm install -g @relaycast/engine
relaycast-engine --db ./relaycast.db --port 8787
```

On start it migrates the database (idempotent), then serves on the port and prints
a hint for creating your first workspace:

```
Relaycast self-host listening on http://localhost:8787 (db: ./relaycast.db)
```
Comment on lines +48 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language identifier to the fenced block.

The code fence at Line 48 has no language, which trips markdown linting (MD040). Use text (or bash if you want it treated as command output).

Suggested patch
-```
+```text
 Relaycast self-host listening on http://localhost:8787 (db: ./relaycast.db)
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 48-48: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/self-hosting.md` around lines 48 - 50, The fenced code block containing
"Relaycast self-host listening on http://localhost:8787 (db: ./relaycast.db)"
lacks a language identifier and triggers MD040; update that fenced block to
include a language (e.g., add "text" or "bash" after the opening ``` fence) so
the block becomes ```text (or ```bash) before the content to satisfy the linter.


`Ctrl-C` shuts down cleanly.

---

## 3. Configuration

Flags take precedence over environment variables.

| Flag | Env var | Default | Purpose |
|---|---|---|---|
| `--db <path>` | `RELAYCAST_DB_PATH` | `./relaycast.db` | SQLite database file (created if absent). Use `:memory:` for throwaway runs. |
| `--port <n>` | `PORT` | `8787` | HTTP/WebSocket listen port. |
| `--base-url <url>` | — | `http://localhost:<port>` | Public origin. **Set this in production** — it's embedded in signed file-upload/download URLs, so it must be the address clients actually reach. |
| `--env <name>` | `RELAYCAST_ENV` | `production` | Environment label used in logs. |

**Telemetry is off by default** — self-host ships a no-op telemetry sink, so
nothing is sent anywhere. (There is no PostHog/analytics in self-host.)

Migrations run automatically on every boot and are tracked in an internal
`_engine_migrations` table, so restarts and upgrades are safe.

---

## 4. Create your first workspace

There's no separate bootstrap command — create a workspace through the API. It
returns a **workspace key** (`rk_live_…`), shown **once**:

```bash
curl -s -XPOST http://localhost:8787/v1/workspaces \
-H 'content-type: application/json' \
-d '{"name":"my-team"}'
# → { "ok": true, "data": { "workspace_id": "…", "api_key": "rk_live_…", … } }
```

Store the `api_key` securely — it's the admin credential for the workspace and
can't be recovered (only rotated). Creating a workspace also seeds a default
`general` channel.

Register agents with the workspace key to get per-agent tokens (`at_live_…`):

```bash
curl -s -XPOST http://localhost:8787/v1/agents \
-H "authorization: Bearer rk_live_…" -H 'content-type: application/json' \
-d '{"name":"alice"}'
# → { "ok": true, "data": { "id": "…", "token": "at_live_…", … } }
```

---

## 5. Connecting clients

Point any Relaycast client at your base URL with a key.

- **SDK** (`@relaycast/sdk`): pass `baseUrl: "http://your-host:8787"` and the
workspace key or agent token. See the SDK docs.
- **Real-time WebSocket**: `ws://your-host:8787/v1/ws?token=<at_live_...>` streams
that agent's events. Workspace-key streams are off by default in self-host; enable
them first if you need an admin-wide stream:

```bash
curl -s -XPUT http://localhost:8787/v1/workspace/stream \
-H "authorization: Bearer rk_live_…" -H 'content-type: application/json' \
-d '{"enabled":true}'
```

Then connect with `ws://your-host:8787/v1/ws?token=<rk_live_...>`.
- **MCP**: point an MCP client at `http://your-host:8787/mcp`, passing the
workspace key via the `x-relay-api-key` header.
- **CLI** (`relaycast`): set `RELAY_BASE_URL=http://your-host:8787` and
`RELAY_API_KEY=rk_live_…`.

### Files

Self-host stores uploaded files on the local filesystem (default:
`<cwd>/relaycast-files`) and serves them through the engine itself via short-lived
**HMAC-signed URLs** at `/_relayfiles`. The upload/download flow is identical to
the hosted (presigned-R2) experience — clients `PUT`/`GET` the signed URL the API
returns. Make sure `--base-url` is the public address so those URLs resolve.

---

## 6. Running in production

Self-host speaks plain HTTP/WS. Put a TLS-terminating reverse proxy in front and
**make sure it forwards WebSocket upgrades**.

**Caddy** (automatic HTTPS):

```caddyfile
relay.example.com {
reverse_proxy localhost:8787
}
```

**nginx** (WebSocket upgrade headers are required):

```nginx
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
```

Then run with the public origin:

```bash
relaycast-engine --db /var/lib/relaycast/relaycast.db --port 8787 \
--base-url https://relay.example.com
```

**systemd** (`/etc/systemd/system/relaycast.service`):

```ini
[Service]
ExecStart=/usr/local/bin/relaycast-engine --db /var/lib/relaycast/relaycast.db --port 8787 --base-url https://relay.example.com
Restart=always
User=relaycast
WorkingDirectory=/var/lib/relaycast
[Install]
WantedBy=multi-user.target
```

**Docker** (build your own image — no official image is published yet):

```dockerfile
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @relaycast/engine
VOLUME /data
WORKDIR /data
EXPOSE 8787
ENV RELAYCAST_DB_PATH=/data/relaycast.db PORT=8787
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
ENTRYPOINT ["relaycast-engine"]
Comment on lines +181 to +189
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current Dockerfile installs build dependencies (build-essential and python3) which are required to compile the native better-sqlite3 module during installation, but are not needed at runtime. Keeping these in the final image significantly increases the image size and the security attack surface.

Using a multi-stage build allows you to compile the dependencies in a builder stage and copy only the compiled node modules and binary to a clean runtime stage, keeping the final production image slim and secure.

Suggested change
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @relaycast/engine
VOLUME /data
EXPOSE 8787
ENV RELAYCAST_DB_PATH=/data/relaycast.db PORT=8787
ENTRYPOINT ["relaycast-engine"]
FROM node:20-bookworm-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 && rm -rf /var/lib/apt/lists/*
RUN npm install -g @relaycast/engine
FROM node:20-bookworm-slim
COPY --from=builder /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=builder /usr/local/bin/relaycast-engine /usr/local/bin/relaycast-engine
VOLUME /data
EXPOSE 8787
ENV RELAYCAST_DB_PATH=/data/relaycast.db PORT=8787
ENTRYPOINT ["relaycast-engine"]

Comment on lines +185 to +189
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the upload directory in the Docker example

With this Dockerfile/run command, only /data is mounted, but the Node adapter writes uploaded blobs to process.cwd()/relaycast-files by default and the image never sets WORKDIR /data or a file-directory option. In the documented Docker deployment, uploaded files therefore land in an unmounted /relaycast-files inside the container and are lost when the container is recreated, even though the database is persisted under /data.

Useful? React with 👍 / 👎.

```

```bash
docker build -t relaycast .
docker run -p 8787:8787 -v "$PWD/data:/data" relaycast --base-url https://relay.example.com
```

---

## 7. Upgrades & backups

- **Back up the SQLite file** (and the files directory) before upgrading. With the
server stopped, copy `relaycast.db` (and `-wal`/`-shm` if present).
- **Upgrade**: `npm install -g @relaycast/engine@latest` (or bump the Docker base),
then restart. New migrations apply automatically on boot; already-applied ones
are skipped.
- Versioning is lockstep across `@relaycast/*`, so a single version bump covers the
engine, CLI, and SDKs.

---

## 8. Limitations

Be honest with yourself about what self-host is and isn't:

- **Single process only — no horizontal scale-out.** Sequence counters, the
WebSocket connection set, presence, the workspace-stream override, the resync
ring, and rate-limit buckets all live in this process's memory. Two
`relaycast-engine` processes would each have independent state and disjoint
sockets. A multi-node deployment needs a shared realtime/persistence backend
(Redis pub/sub for fanout, Postgres for storage) plugged in behind the same
engine ports — **that adapter is a future extension, not shipped today.**
- **In-process background work.** The webhook/notification "queue" and the periodic
A2A health sweep run as in-process timers — they don't survive a restart mid-flight.
- **Counters reset on restart.** Idempotency windows and usage counters are
in-memory (best-effort), so they reset when the process restarts.
- **Workspace-stream overrides reset on restart.** If you enable the workspace-key
WebSocket stream with `PUT /v1/workspace/stream`, repeat that call after restart.
- **Restart resets live realtime sequences.** A restart loses in-memory
`agent_seq`/socket state; reconnecting clients resync from the database.

### What you DON'T get vs the hosted product

| | Self-host | Hosted |
|---|---|---|
| Billing / plan entitlements | static, effectively unlimited single tier | Stripe-backed per-workspace |
| Multi-tenant admin / org management | — | yes |
| Horizontal scaling / multi-region | single process | Durable Objects + edge |
| Managed backups & SLA | you operate the SQLite file + process | managed |
| Product analytics (PostHog) | none (no-op sink) | yes |
| Auth | built-in API keys (`rk_live_`/`at_live_`) | hosted accounts/billing |

You *can* front self-host with your own SSO/proxy, but the shipped default is plain
API keys.

---

## 9. Troubleshooting

- **`better-sqlite3` fails to install** — install a C toolchain
(`build-essential python3`) and reinstall; it compiles a native module.
- **WebSocket won't connect through a proxy** — your reverse proxy isn't forwarding
the `Upgrade`/`Connection` headers (see nginx example above).
- **File download URLs 404 or point at `localhost`** — set `--base-url` to the
public origin.
- **Port already in use** — change `--port` / `PORT`.
36 changes: 31 additions & 5 deletions packages/engine/src/engine/a2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ type Db = ReturnType<typeof getDb>;
const RETRY_DELAYS_MS = [250, 750] as const;
const DEFAULT_WEBHOOK_BASE_PATH = '/a2a/webhook';

type RelayA2aPart =
| { kind: 'text'; text: string }
| {
kind: 'file';
file: {
name: string;
mime_type?: string;
uri?: string;
bytes?: number;
};
}
| { kind: 'data'; data: Record<string, unknown> };

type RelayA2aArtifact = {
parts?: unknown;
};

const RelayFileAttachmentSchema = z.object({
file_id: z.string(),
filename: z.string(),
Expand Down Expand Up @@ -167,7 +184,11 @@ export async function fetchAgentCard(agentCardUrl: string): Promise<A2aAgentCard
return A2aAgentCardSchema.parse(payload);
}

function extractTextFromParts(parts: A2aPart[]): string {
function asRelayA2aParts(parts: unknown): RelayA2aPart[] {
return Array.isArray(parts) ? (parts as RelayA2aPart[]) : [];
}

function extractTextFromParts(parts: RelayA2aPart[]): string {
return parts
.map((part) => {
if (part.kind === 'text') return part.text;
Expand All @@ -178,7 +199,7 @@ function extractTextFromParts(parts: A2aPart[]): string {
.trim();
}

function extractAttachmentsFromParts(parts: A2aPart[]): FileAttachment[] {
function extractAttachmentsFromParts(parts: RelayA2aPart[]): FileAttachment[] {
const attachments: FileAttachment[] = [];

for (const part of parts) {
Expand Down Expand Up @@ -487,20 +508,25 @@ export function translateA2aToRelay(jsonRpc: A2aJsonRpcRequest | A2aJsonRpcRespo

if (parsedRequest.success && parsedRequest.data.params?.message) {
const message = parsedRequest.data.params.message;
const parts = asRelayA2aParts(message.parts);
return {
id: String(parsedRequest.data.id ?? message.message_id),
agent_id: 'external',
agent_name: 'external',
text: extractTextFromParts(message.parts),
text: extractTextFromParts(parts),
thread_id: message.context_id ?? null,
attachments: extractAttachmentsFromParts(message.parts),
attachments: extractAttachmentsFromParts(parts),
};
}

const parsedResponse = JsonRpcResponseSchema.parse(jsonRpc);
const task = parsedResponse.result?.task;
const responseMessage = parsedResponse.result?.message ?? task?.history?.at(-1);
const parts = responseMessage?.parts ?? task?.artifacts?.flatMap((artifact) => artifact.parts) ?? [];
const parts = asRelayA2aParts(
responseMessage?.parts
?? (task?.artifacts as RelayA2aArtifact[] | undefined)?.flatMap((artifact) => asRelayA2aParts(artifact.parts))
?? [],
);

return {
id: String(parsedResponse.id ?? task?.id ?? crypto.randomUUID()),
Expand Down
4 changes: 2 additions & 2 deletions packages/engine/src/routes/agent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Hono } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { z } from 'zod';
import { AgentTypeSchema } from '@relaycast/types';
import type { AppEnv } from '../env.js';
import { requireWorkspaceKey, requireAuth } from '../middleware/auth.js';
import { rateLimit } from '../middleware/rateLimit.js';
Expand All @@ -23,10 +22,11 @@ const skillSchema = z.object({
});

const capabilitiesSchema = z.record(z.string(), z.unknown());
const agentTypeSchema = z.enum(['agent', 'human', 'system']);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Reuse the shared AgentTypeSchema instead of duplicating the enum here. This keeps agent registration validation aligned with packages/types/src/agent.ts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/routes/agent.ts, line 25:

<comment>Reuse the shared `AgentTypeSchema` instead of duplicating the enum here. This keeps agent registration validation aligned with `packages/types/src/agent.ts`.</comment>

<file context>
@@ -23,10 +22,11 @@ const skillSchema = z.object({
 });
 
 const capabilitiesSchema = z.record(z.string(), z.unknown());
+const agentTypeSchema = z.enum(['agent', 'human', 'system']);
 
 const registerAgentSchema = z.object({
</file context>


const registerAgentSchema = z.object({
name: z.string().min(1),
type: AgentTypeSchema.optional(),
type: agentTypeSchema.optional(),
persona: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
skills: z.array(skillSchema).optional(),
Expand Down
Loading
Loading