Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1ad4563
Add the /download page for the desktop app
willwashburn Oct 23, 2026
dbe604d
Send every Log in to /login, tag Teams signup CTAs, and track home, l…
willwashburn Oct 23, 2026
e09f212
Agent signup: visible restart when setup fails, referrer-aware Back, …
willwashburn Oct 23, 2026
5e0a80d
Remove the agents directory Launch agent button and its deploy link
willwashburn Oct 23, 2026
697e937
Bump vendored writing-relayflows to 1.6.4; read multi-file prpm skills
willwashburn Oct 23, 2026
81b8f07
Remove the undeployable probe installer and stale leftovers
willwashburn Oct 23, 2026
7c61cbd
Describe the arelay.to/download redirect without naming its callers
willwashburn Oct 23, 2026
81775f0
Describe the sharing default the agent setup guide applies today
willwashburn Oct 23, 2026
94c0a31
Invite visitors to fork a gallery agent rather than every listed one
willwashburn Oct 23, 2026
5a81471
Merge branch 'main' of https://github.com/AgentWorkforce/agentrelay.c…
willwashburn Oct 23, 2026
a9b2926
Merge branch 'main' of https://github.com/AgentWorkforce/agentrelay.c…
willwashburn Oct 26, 2026
3236a71
Merge main into journey-fixes
willwashburn Oct 26, 2026
b08118d
Verify the Windows installer before bypassing SmartScreen
willwashburn Oct 26, 2026
f026b27
Merge branch 'main' of https://github.com/AgentWorkforce/agentrelay.c…
willwashburn Oct 27, 2026
7b80619
Merge main into journey-fixes
willwashburn Oct 27, 2026
fb2a541
Put /download on the navy landing shell with a column per platform
willwashburn Oct 27, 2026
b9de4d2
Use an installer placeholder in the Windows checksum step
willwashburn Oct 27, 2026
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
37 changes: 37 additions & 0 deletions .claude/skills/writing-relayflows/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,43 @@ Relayflows turns a coding-agent task into steps a journal can inspect, verify, a
- Debugging a `REFUSED [...]` message from `flows check` or `flows run`.
- Deciding between running a flow locally, deploying it to listen for tickets, or scheduling it on a cron.

## Inputs and hosted authoring

The flow body receives **two arguments**: `(f: Ctx, inputs: Input)`. `f` supplies journaled operations; it has no `inputs` property. Declare a concrete input type and read values from the second argument. Do not use `as any` or `@ts-ignore` to make an invented SDK property compile.

```ts
import { flow } from '@relayflows/surface';

type Input = { task: string };
export default flow<Input>('summarize', async (f, inputs) => {
if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs) ||
typeof inputs.task !== 'string') return f.done('declined');
const task = inputs.task.trim();
if (!task) return f.done('declined');
const summary = await f.llm`Summarize this task: ${task}`;
console.log(summary);
f.done('success');
});
```

The `Input` annotation does not validate an invocation at runtime. Validate the
input object and field types before calling string methods or recording effects.
The example declines missing, non-string, empty, and whitespace-only tasks.
Run `node --test scripts/validate-input-example.test.mjs` from this skill directory
to exercise the exact documented callback with inert model and completion stubs.

For a hosted flow, use the host-supplied input schema, trigger configuration, and capabilities. A provider's raw webhook is not necessarily the flow's input: Cloud projects events into normalized records. Manual/API inputs and provider-event inputs are different contracts. If the exact event or required field is unspecified, ask for clarification instead of inventing a payload or falling back to an empty URL/undefined identifier. Keep the chosen event family through deployment: a PR reviewer needs a pull-request source, not an issue source.

For provider reads, prefer connected, documented readers or mounted records. A repository checkout can supply a diff when the required refs are present. A public `diff_url` and unauthenticated `curl` are not a substitute for an authenticated integration, especially for private repositories. Shell commands remain appropriate for deterministic work; do not interpolate untrusted event fields into shell commands. Validate required inputs before recording effects.

Use the published helper signatures. For example, GitHub comments take a target object and body, **two arguments**:

```ts
await f.github.comment({ owner: pr.owner, repo: pr.repo, number: pr.number }, reviewBody);
```

Before accepting generated source, check its SDK calls against declarations and its inputs against the selected host event. Compile-only checks cannot prove a listener supplies the expected input. Exercise the selected event with a representative fixture, including missing required input, without posting to a real provider. Correct contract errors before activation.

## The ladder

Three step verbs, one per rung (`@relayflows/sdk`'s `StepType = 'deterministic' | 'llm' | 'agent'`):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { runInNewContext } from 'node:vm';

// Execute the actual documentation body so a stale copied implementation cannot
// hide a regression. Neither the SDK nor a provider/model is invoked.
const skill = readFileSync(new URL('../SKILL.md', import.meta.url), 'utf8');
const body = /export default flow<Input>\('summarize', async \(f, inputs\) => \{([\s\S]*?)^\}\);/mu.exec(skill)?.[1];
assert.ok(body, 'The documented summarize Flow callback must exist');
const invoke = runInNewContext(`(async (f, inputs) => {${body}})`, {
console: { log() {} },
});

async function exercise(inputs) {
const completions = [];
const modelCalls = [];
await invoke({
done(reason) { completions.push(reason); },
async llm(strings, ...values) {
modelCalls.push({ strings: Array.from(strings), values });
return 'A summary';
},
}, inputs);
return { completions, modelCalls };
}

const invalidCases = [
['missing inputs', undefined],
['null inputs', null],
['boolean inputs', false],
['number inputs', 42],
['string inputs', 'task'],
['array inputs', Object.assign([], { task: 'task' })],
['missing task', {}],
['undefined task', { task: undefined }],
['null task', { task: null }],
['boolean task', { task: false }],
['number task', { task: 42 }],
['array task', { task: ['task'] }],
['object task', { task: { trim() { throw new Error('Must not call trim'); } } }],
['empty task', { task: '' }],
['whitespace task', { task: ' \t\n ' }],
];

for (const [name, inputs] of invalidCases) {
test(`${name} declines without calling the model`, async () => {
assert.deepEqual(await exercise(inputs), { completions: ['declined'], modelCalls: [] });
});
}

for (const task of ['Review the change', ' \tReview the change\n ']) {
test(`valid task ${JSON.stringify(task)} invokes the model once and succeeds`, async () => {
const result = await exercise({ task });
assert.deepEqual(result.completions, ['success']);
assert.equal(result.modelCalls.length, 1);
assert.deepEqual(result.modelCalls[0].values, [task.trim()]);
});
}
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ web/.dev.vars
**/tsconfig.tsbuildinfo

# Runtime data
web/waitlist.json
.agentworkforce/

# Env
Expand All @@ -36,9 +35,6 @@ web/waitlist.json
# Palate website-builder skill telemetry, written at the repo root during agent sessions
build-manifest.json

# Native probe builds are staged locally, not committed with the website.
web/public/downloads/agent-relay-probe/

# Local Claude Code state. Shared skills and handoffs are committed explicitly;
# settings.json is a per-developer permission allowlist and must not be.
.claude/settings.json
Expand Down
10 changes: 5 additions & 5 deletions prpm.lock
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@
"installedPath": ".claude/skills/setting-up-agent-relay-sessions/SKILL.md"
},
"@agent-relay/writing-relayflows#claude": {
"version": "1.6.2",
"resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fwriting-relayflows/1.6.2.tar.gz",
"integrity": "sha256-1b57a97f93f65ef4fc4b2eddadafdedce2d0e7e917bc8efe3d771e8c5d6a431b",
"version": "1.6.4",
"resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fwriting-relayflows/1.6.4.tar.gz",
"integrity": "sha256-716c47c2780aa2ee07dede1d5a2a1f574224f108ad1dfc9b96cc5a054ce66874",
"format": "claude",
"subtype": "skill",
"sourceFormat": "claude",
"sourceSubtype": "skill",
"installedPath": ".claude/skills/writing-relayflows/SKILL.md"
"installedPath": ".claude/skills/writing-relayflows"
},
"@agent-relay/signing-in-to-agent-relay-cloud#claude": {
"version": "0.1.0",
Expand All @@ -53,5 +53,5 @@
"installedPath": ".claude/skills/setting-up-agent-relay-flows/SKILL.md"
}
},
"generated": "2026-10-15T19:24:42.594Z"
"generated": "2026-10-23T06:15:07.751Z"
}
12 changes: 10 additions & 2 deletions router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ const HANDLE_SEGMENT = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])";
const RESERVED_AGENT_PAGE_HANDLES = new Set([
"register", "connect", "api", "cloud", "admin", "www", "help", "support",
"docs", "status", "security", "abuse", "login", "signup", "agents", "directory",
"download",
]);
// Desktop download links name arelay.to/download; the page lives on the
// primary host.
const DOWNLOAD_PAGE_PATH = "/download";
// The verified-agent directory page. web/lib/agent-directory.ts names the same
// path (AGENT_DIRECTORY_PATH); keep the two in step.
const AGENT_DIRECTORY_PAGE_PATH = "/directory";
Expand Down Expand Up @@ -356,9 +360,13 @@ export function isRelayAgentRegistryRoute(
return REGISTRY_API_PATH.test(pathname);
}

// arelay.to only fronts agent chat: its root, its www alias and the signed-in
// cloud app redirect to the primary host.
// arelay.to only fronts agent chat: its root, the download page, its www alias
// and the signed-in cloud app redirect to the primary host.
export function getShortHostRedirect(url: URL): string | undefined {
const shortHost = url.hostname === SHORT_HOST || url.hostname === SHORT_HOST_WWW;
if (shortHost && url.pathname.replace(/\/$/, "") === DOWNLOAD_PAGE_PATH) {
return `https://${PRIMARY_HOST}${DOWNLOAD_PAGE_PATH}${url.search}`;
}
if (url.hostname === SHORT_HOST_WWW) {
return `https://${SHORT_HOST}${url.pathname}${url.search}`;
}
Expand Down
6 changes: 5 additions & 1 deletion router/test/agent-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ describe("router agent chat", () => {
expect(getShortHostRedirect(new URL("https://www.arelay.to/agent-relay"))).toBe("https://arelay.to/agent-relay");
expect(getShortHostRedirect(new URL("https://arelay.to/agent-relay"))).toBeUndefined();
expect(getShortHostRedirect(new URL("https://agentrelay.com/"))).toBeUndefined();
expect(getShortHostRedirect(new URL("https://arelay.to/download"))).toBe("https://agentrelay.com/download");
expect(getShortHostRedirect(new URL("https://arelay.to/download/?os=linux"))).toBe("https://agentrelay.com/download?os=linux");
expect(getShortHostRedirect(new URL("https://www.arelay.to/download"))).toBe("https://agentrelay.com/download");
expect(getShortHostRedirect(new URL("https://agentrelay.com/download"))).toBeUndefined();
});

it("serves agent pages at the arelay.to root and under /u on agentrelay.com", () => {
Expand Down Expand Up @@ -381,7 +385,7 @@ describe("router agent chat", () => {
expect(getAgentPagePath("arelay.to", "/agent-relay/bridge.sh", "GET", "*/*")).toBeUndefined();
expect(getAgentPagePath("arelay.to", "/-invalid", "GET", "*/*")).toBeUndefined();
expect(getAgentPagePath("arelay.to", "/x", "GET", "*/*")).toBeUndefined();
for (const reserved of ["register", "connect", "api", "cloud", "admin", "www", "help", "support", "docs", "status", "security", "abuse", "login", "signup"]) {
for (const reserved of ["register", "connect", "api", "cloud", "admin", "www", "help", "support", "docs", "status", "security", "abuse", "login", "signup", "download"]) {
expect(getAgentPagePath("arelay.to", `/${reserved}`, "GET", "*/*")).toBeUndefined();
}
expect(getAgentPagePath("arelay.to", "/agent-relay", "POST", "*/*")).toBeUndefined();
Expand Down
18 changes: 0 additions & 18 deletions scripts/stage-probe.mjs

This file was deleted.

85 changes: 85 additions & 0 deletions web/app/download/DownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use client';

import { useEffect, useState } from 'react';

import {
alternateDesktopAsset,
type DesktopPlatform,
desktopAssetUrl,
detectDesktopPlatform,
primaryDesktopAsset,
} from '../../lib/desktop-downloads';
import s from './download.module.css';

type UADataNavigator = Navigator & {
userAgentData?: { getHighEntropyValues(hints: string[]): Promise<{ architecture?: string }> };
};

/** What the server renders, and what a visitor whose OS can't be read gets: every platform. */
const EVERY_PLATFORM: readonly DesktopPlatform[] = [
{ os: 'macos', arch: 'arm64' },
{ os: 'linux', arch: 'x64' },
{ os: 'windows', arch: 'x64' },
];

/** Leads with the build for the visitor's OS and architecture. */
export function DownloadButton() {
const [platform, setPlatform] = useState<DesktopPlatform | null>(null);

useEffect(() => {
let cancelled = false;
const nav = navigator as UADataNavigator;
const detect = (architecture?: string) => {
if (!cancelled) {
setPlatform(detectDesktopPlatform(nav.userAgent, { architecture, touchPoints: nav.maxTouchPoints }));
}
};
detect();
nav.userAgentData
?.getHighEntropyValues(['architecture'])
.then(({ architecture }) => detect(architecture || undefined))
.catch(() => {});
return () => {
cancelled = true;
};
}, []);

if (!platform) {
return (
<div className={s.actions}>
<div className={s.choices}>
{EVERY_PLATFORM.map((choice) => {
const asset = primaryDesktopAsset(choice);
return (
<a key={asset.file} className={`btn btn-secondary ${s.choice}`} href={desktopAssetUrl(asset.file)}>
{asset.label}
<span className={s.choiceKind}>{asset.kind}</span>
</a>
);
})}
</div>
<p className={s.heroNote}>
Other builds are in <a href="#all-downloads">all downloads</a>.
</p>
</div>
);
}

const asset = primaryDesktopAsset(platform);
const alternate = alternateDesktopAsset(platform);

return (
<div className={s.actions}>
<a className={`btn btn-primary ${s.primary}`} href={desktopAssetUrl(asset.file)}>
Download for {asset.label}
</a>
<p className={s.heroNote}>
{asset.kind} · <a href={desktopAssetUrl(`${asset.file}.sha256`)}>SHA-256</a> · Not your machine?{' '}
<a href={desktopAssetUrl(alternate.file)}>
{alternate.label} {alternate.kind}
</a>{' '}
or <a href="#all-downloads">all downloads</a>
</p>
</div>
);
}
Loading
Loading